import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/constants/market_list_layout.dart'; import '../../../core/l10n/app_localizations.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/utils/number_format.dart'; import '../../../core/utils/symbol_display.dart'; import '../../../providers/market_provider.dart'; import '../../widgets/common/app_refresh_indicator.dart'; import '../../widgets/common/app_shimmer.dart'; import '../../widgets/common/coin_icon.dart'; /// 行情页列表点击:按当前选中的「永续 / 现货」Tab 跳转对应 K 线详情。 void _pushMarketQuoteDetail( BuildContext context, WidgetRef ref, String rawSymbol, ) { final mode = ref.read(marketProvider).mode; final sym = rawSymbol.replaceAll('/', '').replaceAll('-', '').toUpperCase(); if (sym.isEmpty) return; final path = mode == MarketMode.futures ? '/market/futures/$sym' : '/market/spot/$sym'; context.push(path); } class MarketScreen extends ConsumerWidget { const MarketScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final mode = ref.watch(marketProvider.select((s) => s.mode)); final isLoading = ref.watch(marketProvider.select( (s) => mode == MarketMode.futures ? s.isLoading : s.spotLoading)); return Scaffold( body: SafeArea( child: Column( children: [ _SearchBar( onChanged: ref.read(marketProvider.notifier).setSearch, ), // ── 现货 / 合约 Tab 切换 ────────────────────── _MarketTabBar( mode: mode, onChanged: ref.read(marketProvider.notifier).setMode, ), Expanded( child: isLoading ? const _MarketShimmer() : mode == MarketMode.futures ? const _MarketList() : const _SpotMarketList(), ), ], ), ), ); } } // ── 现货/合约 Tab 栏 ────────────────────────────────────── class _MarketTabBar extends StatelessWidget { const _MarketTabBar({required this.mode, required this.onChanged}); final MarketMode mode; final ValueChanged onChanged; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; Widget tab(String label, MarketMode value) { final active = mode == value; return Expanded( child: GestureDetector( onTap: () => onChanged(value), behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: active ? AppColors.brand : Colors.transparent, width: 2, ), ), ), alignment: Alignment.center, child: Text( label, style: TextStyle( color: active ? cs.onSurface : cs.onSurface.withAlpha(140), fontSize: 14, fontWeight: active ? FontWeight.w600 : FontWeight.w400, ), ), ), ), ); } return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ tab(l10n.perpetualFutures, MarketMode.futures), tab(l10n.spotTab, MarketMode.spot), ], ), ); } } // ── 搜索框 ──────────────────────────────────────────────── class _SearchBar extends StatelessWidget { const _SearchBar({required this.onChanged}); final ValueChanged onChanged; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 6), child: SizedBox( height: 38, child: Semantics( label: 'market_search_input', textField: true, child: TextField( onChanged: onChanged, style: TextStyle(color: cs.onSurface, fontSize: 13), textAlignVertical: TextAlignVertical.center, decoration: InputDecoration( hintText: AppLocalizations.of(context)!.searchMarket, hintStyle: TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 13), prefixIcon: Icon(Icons.search, color: cs.onSurface.withAlpha(100), size: 18), prefixIconConstraints: const BoxConstraints(minWidth: 40), isDense: true, filled: true, fillColor: cs.surface, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(24), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(24), borderSide: BorderSide.none, ), ), ), ), ), ); } } // ── 行情列表主体 ────────────────────────────────────────── // 只 select displaySymbols(symbol 列表),价格变化不触发列表重建。 // 各行通过 tickerProvider(symbol) 独立订阅自己的 ticker 数据。 class _MarketList extends ConsumerWidget { const _MarketList(); @override Widget build(BuildContext context, WidgetRef ref) { final symbols = ref.watch(marketProvider.select((s) => s.displaySymbols)); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // "永续合约" 标题 Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), child: Text( AppLocalizations.of(context)!.perpetualFutures, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: Theme.of(context).colorScheme.onSurface, ), ), ), // 列表表头 const _ListHeader(), // 行情行列表 Expanded( child: AppRefreshIndicator( onRefresh: () => ref.read(marketProvider.notifier).refresh(), child: ListView.builder( itemCount: symbols.length, itemBuilder: (context, index) { // 只传 symbol,不传 ticker 对象 return _TickerRow(symbol: symbols[index]); }, ), ), ), ], ); } } // ── 现货行情列表 ────────────────────────────────────────── class _SpotMarketList extends ConsumerWidget { const _SpotMarketList(); @override Widget build(BuildContext context, WidgetRef ref) { final symbols = ref.watch(marketProvider.select((s) => s.spotDisplaySymbols)); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), child: Text( AppLocalizations.of(context)!.spotTab, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: Theme.of(context).colorScheme.onSurface, ), ), ), const _SpotListHeader(), Expanded( child: AppRefreshIndicator( onRefresh: () => ref.read(marketProvider.notifier).refresh(), child: symbols.isEmpty ? Center( child: Text( AppLocalizations.of(context)!.noOrders, style: TextStyle( color: Theme.of(context) .colorScheme .onSurface .withAlpha(140), fontSize: 14, ), ), ) : ListView.builder( itemCount: symbols.length, itemBuilder: (context, index) => _SpotTickerRow(symbol: symbols[index]), ), ), ), ], ); } } // ── 现货列表表头 ────────────────────────────────────────── class _SpotListHeader extends ConsumerWidget { const _SpotListHeader(); @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final sortField = ref.watch(marketProvider.select((s) => s.spotSortField)); final sortAsc = ref.watch(marketProvider.select((s) => s.spotSortAsc)); Widget sortCell({ required String label, required MarketSortField field, MainAxisAlignment align = MainAxisAlignment.start, }) { final active = sortField == field; final color = active ? cs.onSurface : cs.onSurface.withAlpha(120); return GestureDetector( onTap: () => ref.read(marketProvider.notifier).toggleSpotSort(field), behavior: HitTestBehavior.opaque, child: Row( mainAxisAlignment: align, mainAxisSize: MainAxisSize.min, children: [ Text(label, style: TextStyle(color: color, fontSize: 12)), const SizedBox(width: 2), _SortIcon(active: active, asc: sortAsc, color: color), ], ), ); } Widget trailingSortCol({ required String label, required MarketSortField field, }) { final active = sortField == field; final color = active ? cs.onSurface : cs.onSurface.withAlpha(120); return SizedBox( width: kMarketListChangeBadgeWidth, child: GestureDetector( onTap: () => ref.read(marketProvider.notifier).toggleSpotSort(field), behavior: HitTestBehavior.opaque, child: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerRight, child: Row( mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ Text(label, style: TextStyle(color: color, fontSize: 12)), const SizedBox(width: 2), _SortIcon(active: active, asc: sortAsc, color: color), ], ), ), ), ); } return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), child: Row( children: [ Expanded( flex: kMarketListNameClusterFlex, child: sortCell( label: AppLocalizations.of(context)!.nameVolume, field: MarketSortField.volume, ), ), SizedBox(width: kMarketListNameToPriceGap), sortCell( label: AppLocalizations.of(context)!.latestPriceFull, field: MarketSortField.price, align: MainAxisAlignment.end, ), Spacer(flex: kMarketListPriceTailSpacerFlex), SizedBox(width: kMarketListPriceToBadgeGap), trailingSortCol( label: AppLocalizations.of(context)!.change24hFull, field: MarketSortField.change, ), ], ), ); } } // ── 现货行情行 ──────────────────────────────────────────── class _SpotTickerRow extends ConsumerWidget { const _SpotTickerRow({required this.symbol}); final String symbol; static String _formatVolume(double v) { if (v >= 1e9) return '${(v / 1e9).toStringAsFixed(2)}B'; if (v >= 1e6) return '${(v / 1e6).toStringAsFixed(2)}M'; if (v >= 1e3) return '${(v / 1e3).toStringAsFixed(2)}K'; return v.toStringAsFixed(2); } @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final ticker = ref.watch(spotTickerProvider(symbol)); if (ticker == null) return const SizedBox.shrink(); final volumeStr = _formatVolume(ticker.volume24h); final changeColor = AppColors.changeColor(ticker.change24h); final changeStr = formatChange(ticker.change24h); return InkWell( onTap: () => _pushMarketQuoteDetail(context, ref, ticker.symbol), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Expanded( flex: kMarketListNameClusterFlex, child: Row( children: [ CoinIcon( symbol: ticker.baseAsset, iconUrl: ticker.icon, size: 30), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( formatUsdtPairDisplay(ticker.symbol), style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w600, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( '${AppLocalizations.of(context)!.spot} · $volumeStr', style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), ], ), ), SizedBox(width: kMarketListNameToPriceGap), Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ Text( ticker.lastPrice > 0 ? (ticker.lastPriceStr != null ? formatRawPrice(ticker.lastPriceStr!) : formatPrice(ticker.lastPrice)) : '--', style: TextStyle( color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w500, fontFeatures: const [FontFeature.tabularFigures()], ), textAlign: TextAlign.end, maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( ticker.lastPrice > 0 ? formatFiatPrice(ticker.lastPrice, pricePrecision: ticker.pricePrecision) : '--', style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11, fontFeatures: const [FontFeature.tabularFigures()], ), textAlign: TextAlign.end, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), Spacer(flex: kMarketListPriceTailSpacerFlex), SizedBox(width: kMarketListPriceToBadgeGap), SizedBox( width: kMarketListChangeBadgeWidth, child: Container( height: 34, decoration: BoxDecoration( color: changeColor, borderRadius: BorderRadius.circular(6), ), alignment: Alignment.center, child: Text( ticker.lastPrice > 0 ? changeStr : '--', style: const TextStyle( color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600, fontFeatures: [FontFeature.tabularFigures()], ), textAlign: TextAlign.center, ), ), ), ], ), ), ); } } // ── 列表表头(合约) ────────────────────────────────────────── class _ListHeader extends ConsumerWidget { const _ListHeader(); @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final sortField = ref.watch(marketProvider.select((s) => s.sortField)); final sortAsc = ref.watch(marketProvider.select((s) => s.sortAsc)); Widget buildSortCell({ required String label, required MarketSortField field, MainAxisAlignment align = MainAxisAlignment.start, required String semanticsLabel, }) { final active = sortField == field; final color = active ? cs.onSurface : cs.onSurface.withAlpha(120); return Semantics( label: semanticsLabel, button: true, onTap: () => ref.read(marketProvider.notifier).toggleSort(field), child: GestureDetector( onTap: () => ref.read(marketProvider.notifier).toggleSort(field), behavior: HitTestBehavior.opaque, child: Row( mainAxisAlignment: align, mainAxisSize: MainAxisSize.min, children: [ Text(label, style: TextStyle(color: color, fontSize: 12)), const SizedBox(width: 2), _SortIcon(active: active, asc: sortAsc, color: color), ], ), ), ); } Widget buildTrailingSortCol({ required String label, required MarketSortField field, required String semanticsLabel, }) { final active = sortField == field; final color = active ? cs.onSurface : cs.onSurface.withAlpha(120); return SizedBox( width: kMarketListChangeBadgeWidth, child: Semantics( label: semanticsLabel, button: true, onTap: () => ref.read(marketProvider.notifier).toggleSort(field), child: GestureDetector( onTap: () => ref.read(marketProvider.notifier).toggleSort(field), behavior: HitTestBehavior.opaque, child: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerRight, child: Row( mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ Text(label, style: TextStyle(color: color, fontSize: 12)), const SizedBox(width: 2), _SortIcon(active: active, asc: sortAsc, color: color), ], ), ), ), ), ); } return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), child: Row( children: [ Expanded( flex: kMarketListNameClusterFlex, child: buildSortCell( label: AppLocalizations.of(context)!.nameVolume, field: MarketSortField.volume, semanticsLabel: 'market_sort_volume'), ), SizedBox(width: kMarketListNameToPriceGap), buildSortCell( label: AppLocalizations.of(context)!.latestPriceFull, field: MarketSortField.price, align: MainAxisAlignment.end, semanticsLabel: 'market_sort_price'), Spacer(flex: kMarketListPriceTailSpacerFlex), SizedBox(width: kMarketListPriceToBadgeGap), buildTrailingSortCol( label: AppLocalizations.of(context)!.change24hFull, field: MarketSortField.change, semanticsLabel: 'market_sort_change'), ], ), ); } } /// 排序箭头图标:上下双三角,激活时高亮当前方向 class _SortIcon extends StatelessWidget { const _SortIcon( {required this.active, required this.asc, required this.color}); final bool active; final bool asc; final Color color; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final dim = cs.onSurface.withAlpha(50); return SizedBox( width: 12, height: 16, child: Stack( children: [ Positioned( top: 0, left: 0, right: 0, child: Icon(Icons.arrow_drop_up, size: 14, color: active && asc ? color : dim), ), Positioned( bottom: 0, left: 0, right: 0, child: Icon(Icons.arrow_drop_down, size: 14, color: active && !asc ? color : dim), ), ], ), ); } } // ── 行情行 ──────────────────────────────────────────────── // 每行通过 tickerProvider(symbol) 独立订阅, // BTC 价格变化只重建 BTC 行,不影响其他行。 class _TickerRow extends ConsumerWidget { const _TickerRow({required this.symbol}); final String symbol; static String _formatVolume(double v) { if (v >= 1e9) return '${(v / 1e9).toStringAsFixed(2)}B'; if (v >= 1e6) return '${(v / 1e6).toStringAsFixed(2)}M'; if (v >= 1e3) return '${(v / 1e3).toStringAsFixed(2)}K'; return v.toStringAsFixed(2); } @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final ticker = ref.watch(tickerProvider(symbol)); if (ticker == null) return const SizedBox.shrink(); final volumeStr = _formatVolume(ticker.volume24h); final changeColor = AppColors.changeColor(ticker.change24h); final changeStr = formatChange(ticker.change24h); return Semantics( label: 'market_item_${ticker.symbol}', button: true, onTap: () => _pushMarketQuoteDetail(context, ref, ticker.symbol), child: InkWell( onTap: () => _pushMarketQuoteDetail(context, ref, ticker.symbol), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11), child: Row( children: [ // 头像 Expanded( flex: kMarketListNameClusterFlex, child: Row( children: [ CoinIcon( symbol: ticker.baseAsset, iconUrl: ticker.icon, size: 40, borderRadius: 12, ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( formatUsdtPairDisplay(ticker.symbol), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w500, ), ), Text( '${AppLocalizations.of(context)!.perpetual} · $volumeStr', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 12), ), ], ), ), ], ), ), SizedBox(width: kMarketListNameToPriceGap), Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ Text( ticker.lastPriceStr != null ? formatRawPrice(ticker.lastPriceStr!) : formatPrice(ticker.lastPrice), style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w500, fontFeatures: const [FontFeature.tabularFigures()], ), textAlign: TextAlign.end, maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( formatFiatPrice(ticker.lastPrice, pricePrecision: ticker.pricePrecision), style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11, fontFeatures: const [FontFeature.tabularFigures()], ), textAlign: TextAlign.end, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), Spacer(flex: kMarketListPriceTailSpacerFlex), const SizedBox(width: kMarketListPriceToBadgeGap), // 涨跌幅 Badge(固定宽度,避免内容宽度抖动) SizedBox( width: kMarketListChangeBadgeWidth, child: Container( height: 34, decoration: BoxDecoration( color: changeColor, borderRadius: BorderRadius.circular(6), ), alignment: Alignment.center, child: Text( changeStr, style: const TextStyle( color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600, fontFeatures: [FontFeature.tabularFigures()], ), textAlign: TextAlign.center, ), ), ), ], ), ), ), ); } } // ── 行情骨架屏 ────────────────────────────────────────────── class _MarketShimmer extends StatelessWidget { const _MarketShimmer(); @override Widget build(BuildContext context) { return AppShimmer( child: ListView.builder( physics: const NeverScrollableScrollPhysics(), itemCount: 10, itemBuilder: (_, __) => Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Expanded( flex: kMarketListNameClusterFlex, child: Row( children: [ shimmerCircle(36), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(70, 14), const SizedBox(height: 6), shimmerBox(50, 11), ], ), ), ], ), ), SizedBox(width: kMarketListNameToPriceGap), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ shimmerBox(80, 14), const SizedBox(height: 6), shimmerBox(50, 11), ], ), Spacer(flex: kMarketListPriceTailSpacerFlex), const SizedBox(width: kMarketListPriceToBadgeGap), shimmerBox(kMarketListChangeBadgeWidth, 30, radius: 6), ], ), ), ), ); } }