import 'dart:io'; import 'dart:ui' as ui; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:k_chart_plus/chart_translations.dart'; import 'package:k_chart_plus/k_chart_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.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 '../../../core/utils/spot_order_book_convert.dart'; import '../../../core/utils/top_toast.dart'; import '../../../data/models/market/funding_rate.dart'; import '../../../data/models/market/order_book_entry.dart'; import '../../../providers/funding_rate_provider.dart'; import '../../../providers/futures_provider.dart'; import '../../../providers/market_detail_provider.dart'; import '../../../providers/spot_provider.dart'; import '../../widgets/common/app_shimmer.dart'; import '../../widgets/common/coin_icon.dart'; import '../../widgets/common/symbol_picker_sheet.dart'; /// K 线详情页骨架(现货 / 永续分别用 [SpotMarketDetailScreen]、[FuturesMarketDetailScreen] 入口)。 class MarketDetailScaffold extends ConsumerStatefulWidget { const MarketDetailScaffold({super.key, required this.marketKey}); final MarketDetailKey marketKey; @override ConsumerState createState() => _MarketDetailScaffoldState(); } class _MarketDetailScaffoldState extends ConsumerState { final _shareKey = GlobalKey(); bool _sharing = false; @override void initState() { super.initState(); // provider 无 autoDispose,state 跨页面保留;进入页面时强制重置到"行情"tab WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ref.read(marketDetailProvider(widget.marketKey).notifier).setTopTab(0); }); } Future _handleShare() async { if (_sharing) return; if (!mounted) return; setState(() => _sharing = true); try { final boundary = _shareKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; if (boundary == null) return; final image = await boundary.toImage(pixelRatio: 2.5); final byteData = await image.toByteData(format: ui.ImageByteFormat.png); final bytes = byteData!.buffer.asUint8List(); final tempDir = await getTemporaryDirectory(); final file = File( '${tempDir.path}/market_${DateTime.now().millisecondsSinceEpoch}.png'); await file.writeAsBytes(bytes); // 先重置状态,再调用分享(避免用户取消时系统面板不 resolve 导致按钮卡转圈) if (mounted) setState(() => _sharing = false); Share.shareXFiles([XFile(file.path)], text: widget.marketKey.symbol); } catch (_) { if (mounted) { showTopToast(context, message: AppLocalizations.of(context)!.shareFailed, backgroundColor: AppColors.fall); } } finally { if (mounted) setState(() => _sharing = false); } } @override Widget build(BuildContext context) { final key = widget.marketKey; final isLoading = ref.watch( marketDetailProvider(key).select((s) => s.isLoading), ); return Scaffold( appBar: _SymbolAppBar( marketKey: key, onShare: _handleShare, sharing: _sharing, ), body: RepaintBoundary( key: _shareKey, child: isLoading ? const _MarketDetailShimmer() : _DetailBody(marketKey: key), ), bottomNavigationBar: _BottomActions(marketKey: key), ); } } // ── 顶部 AppBar(含币对切换)───────────────────────────────── class _SymbolAppBar extends ConsumerWidget implements PreferredSizeWidget { const _SymbolAppBar({ required this.marketKey, required this.onShare, this.sharing = false, }); final MarketDetailKey marketKey; final VoidCallback onShare; final bool sharing; String get symbol => marketKey.symbol; bool get isFutures => marketKey.isFutures; @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); @override Widget build(BuildContext context, WidgetRef ref) { return AppBar( // 默认 titleSpacing 约 16,会在 leading 与 title 之间留出一块空白 titleSpacing: 0, leadingWidth: 30, leading: IconButton( icon: const Icon(Icons.arrow_back_ios, size: 18), onPressed: () { if (context.canPop()) { context.pop(); } else { context.go('/market'); } }, padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, constraints: const BoxConstraints(minWidth: 40, minHeight: 40), ), title: Semantics( label: 'market_detail_symbol_picker', button: true, onTap: () => _showSymbolPicker(context, ref), child: GestureDetector( onTap: () => _showSymbolPicker(context, ref), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text(formatUsdtPairDisplay(symbol), style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w600)), const SizedBox(width: 4), const Icon(Icons.keyboard_arrow_down, size: 18), ], ), ), ), actions: [ Semantics( label: 'market_detail_btn_share', button: true, enabled: !sharing, onTap: onShare, child: IconButton( icon: sharing ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.share_outlined, size: 20), onPressed: sharing ? null : onShare, ), ), ], ); } void _showSymbolPicker(BuildContext context, WidgetRef ref) { showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (sheetCtx) => SymbolPickerSheet( currentSymbol: symbol, initialTab: isFutures ? SymbolPickerTab.futures : SymbolPickerTab.spot, visibleTabs: [ if (isFutures) SymbolPickerTab.futures else SymbolPickerTab.spot, ], onSelected: (newSymbol) { Navigator.pop(sheetCtx); if (newSymbol != symbol) { context.replace(isFutures ? '/market/futures/$newSymbol' : '/market/spot/$newSymbol'); } }, onSpotSelected: (newSymbol) { Navigator.pop(sheetCtx); if (newSymbol != symbol) { context.replace(isFutures ? '/market/futures/$newSymbol' : '/market/spot/$newSymbol'); } }, ), ); } } // ── 详情主体 ────────────────────────────────────────────── // 页面主体:只传 symbol,各子组件自行 select 需要的数据。 // 按 topTab 切换行情/概览内容,未激活的 tab 不构建。 class _DetailBody extends ConsumerWidget { const _DetailBody({required this.marketKey}); final MarketDetailKey marketKey; @override Widget build(BuildContext context, WidgetRef ref) { final symbol = marketKey.symbol; final topTab = ref.watch( marketDetailProvider(marketKey).select((s) => s.topTab), ); Widget content; if (topTab == 0) { content = _MarketContent(marketKey: marketKey); } else if (topTab == 1) { content = _InfoTab(marketKey: marketKey); } else { content = _DataTab(symbol: symbol); } return Column( children: [ _TopTabs(marketKey: marketKey), Expanded(child: content), ], ); } } // 整个页面可上下滚动。K 线图用固定高度 450。 // KChartWidget 内部用 onHorizontalDrag 处理水平拖拽, // Flutter 手势竞技场自动区分水平/垂直 — 垂直滑动归 SingleChildScrollView。 // 不需要动态切换 physics。 class _MarketContent extends StatefulWidget { const _MarketContent({required this.marketKey}); final MarketDetailKey marketKey; @override State<_MarketContent> createState() => _MarketContentState(); } class _MarketContentState extends State<_MarketContent> { final _dismissNotifier = ValueNotifier(false); // 用于判断点击位置是否在 K 线图区域内 final _chartKey = GlobalKey(); @override void dispose() { _dismissNotifier.dispose(); super.dispose(); } /// 点击 K 线图区域外部才触发 dismiss; /// 图表内部的 tap 由 KChartWidget 自身处理(展示/切换蜡烛详情)。 void _onTapDown(TapDownDetails details) { final ro = _chartKey.currentContext?.findRenderObject() as RenderBox?; if (ro != null) { final local = ro.globalToLocal(details.globalPosition); if (local.dx >= 0 && local.dx <= ro.size.width && local.dy >= 0 && local.dy <= ro.size.height) { return; // 点击在图表内部,交给 KChartWidget 处理 } } _dismissNotifier.value = !_dismissNotifier.value; } @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.translucent, onTapDown: _onTapDown, child: SingleChildScrollView( physics: const ClampingScrollPhysics(), child: Column( children: [ _PriceHeader(marketKey: widget.marketKey), _PeriodTabBar(marketKey: widget.marketKey), SizedBox( key: _chartKey, child: _KlineChartArea( marketKey: widget.marketKey, dismissNotifier: _dismissNotifier)), _OrderBookSection(marketKey: widget.marketKey), const SizedBox(height: 16), ], ), ), ); } } // ── 顶部 Tab(价格/信息)──────────────────────────────────── // 只 select topTab,行情/概览切换不触发其他区域重建 class _TopTabs extends ConsumerWidget { const _TopTabs({required this.marketKey}); final MarketDetailKey marketKey; List _getLabels(BuildContext context) { final l10n = AppLocalizations.of(context)!; final tabs = [l10n.market, l10n.marketOverview]; if (marketKey.isFutures) tabs.add(l10n.marketData); return tabs; } @override Widget build(BuildContext context, WidgetRef ref) { final provider = marketDetailProvider(marketKey); final topTab = ref.watch(provider.select((s) => s.topTab)); final notifier = ref.read(provider.notifier); final cs = Theme.of(context).colorScheme; final semanticsLabels = [ 'market_detail_tab_chart', 'market_detail_tab_overview', if (marketKey.isFutures) 'market_detail_tab_data', ]; return Container( decoration: BoxDecoration( border: Border(bottom: BorderSide(color: cs.outline)), ), child: Row( children: List.generate(_getLabels(context).length, (i) { final selected = i == topTab; return Semantics( label: semanticsLabels[i], button: true, enabled: true, onTap: () => notifier.setTopTab(i), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => notifier.setTopTab(i), child: Padding( padding: const EdgeInsets.only(left: 16, right: 8), child: Column( children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Text( _getLabels(context)[i], style: TextStyle( fontSize: 14, color: selected ? cs.onSurface : cs.onSurface.withAlpha(153), fontWeight: selected ? FontWeight.w600 : FontWeight.w400, ), ), ), // 未选中时用透明占位保持高度一致,避免切换时布局跳动 Container( height: 2, width: 24, color: selected ? AppColors.brand : Colors.transparent, ), ], ), ), ), ); }), ), ); } } // ── 价格区 ──────────────────────────────────────────────── // 只 select stats,K线更新不会触发价格区重建 class _PriceHeader extends ConsumerWidget { const _PriceHeader({required this.marketKey}); final MarketDetailKey marketKey; @override Widget build(BuildContext context, WidgetRef ref) { final symbol = marketKey.symbol; final isFutures = marketKey.isFutures; final cs = Theme.of(context).colorScheme; final stats = ref.watch( marketDetailProvider(marketKey).select((s) => s.stats), ); if (stats == null) return const SizedBox.shrink(); final priceColor = AppColors.changeColor(stats.change24h); final sign = stats.change24h >= 0 ? '+' : ''; // 提取基础币名(BTCUSDT → BTC) final baseCoin = symbol.toUpperCase().replaceFirst(RegExp(r'USDT$'), ''); return Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 大价格:用主文字色,禁止跟随涨跌色(规范 1.2 节) Text( stats.lastPriceStr != null ? formatRawPrice(stats.lastPriceStr!) : formatPrice(stats.lastPrice), style: TextStyle( color: cs.onSurface, fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.5, fontFeatures: const [FontFeature.tabularFigures()], ), ), const SizedBox(height: 2), // 副行:≈$xxx | 涨跌幅 |(合约:标记价格) Row( children: [ Flexible( child: Text( '≈ \$${formatPrice(stats.lastPrice)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11), ), ), const SizedBox(width: 8), Text( '$sign${stats.change24h.toStringAsFixed(2)}%', style: TextStyle( color: priceColor, fontSize: 11, fontWeight: FontWeight.w500), ), if (isFutures) ...[ const SizedBox(width: 8), Flexible( child: Text( '${AppLocalizations.of(context)!.markPrice} ${formatPrice(stats.markPrice)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11), ), ), ], ], ), const SizedBox(height: 8), // 合约:3 列(含资金费率/倒计时);现货:2 列 Row( children: [ // 列1:最高/最低 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _StatItem( label: AppLocalizations.of(context)!.high24h, value: formatPrice(stats.high24h), color: AppColors.rise), const SizedBox(height: 6), _StatItem( label: AppLocalizations.of(context)!.low24h, value: formatPrice(stats.low24h), color: AppColors.fall), ], ), ), // 列2:成交量/额 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _StatItem( label: AppLocalizations.of(context)! .volume24hLabel(baseCoin), value: stats.volume24h >= 1000 ? '${(stats.volume24h / 1000).toStringAsFixed(2)}K' : stats.volume24h.toStringAsFixed(2), ), const SizedBox(height: 6), _StatItem( label: AppLocalizations.of(context)!.turnover24hLabel, value: stats.turnover24h >= 1e6 ? '${(stats.turnover24h / 1e6).toStringAsFixed(2)}M' : '${(stats.turnover24h / 1000).toStringAsFixed(2)}K', ), ], ), ), if (isFutures) Expanded( child: _FundingColumn(symbol: symbol), ), ], ), ], ), ); } } // 资金费率 + 倒计时列(仅永续合约行情页展示) class _FundingColumn extends ConsumerWidget { const _FundingColumn({required this.symbol}); final String symbol; @override Widget build(BuildContext context, WidgetRef ref) { final fundingRate = ref.watch( futuresProvider(symbol).select((s) => s.fundingRate), ); final fundingCountdown = ref.watch( futuresProvider(symbol).select((s) => s.fundingCountdown), ); final sign = fundingRate >= 0 ? '+' : ''; final rate = '$sign${(fundingRate * 100).toStringAsFixed(4)}%'; final countdown = fundingCountdown; return Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ _StatItem( label: AppLocalizations.of(context)!.fundingRate, value: rate, align: TextAlign.right), const SizedBox(height: 6), _StatItem( label: AppLocalizations.of(context)!.countdown, value: countdown, align: TextAlign.right), ], ); } } class _StatItem extends StatelessWidget { const _StatItem( {required this.label, required this.value, this.color, this.align}); final String label; final String value; final Color? color; final TextAlign? align; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Column( crossAxisAlignment: align == TextAlign.right ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ Text(label, textAlign: align, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 9)), const SizedBox(height: 1), Text( value, textAlign: align, style: TextStyle( color: color ?? cs.onSurface, fontSize: 11, fontWeight: FontWeight.w600, ), ), ], ); } } String _klinePeriodLabel(KlinePeriod p, AppLocalizations l10n) { switch (p) { case KlinePeriod.min1: return l10n.klinePeriod1m; case KlinePeriod.min5: return l10n.klinePeriod5m; case KlinePeriod.min15: return l10n.klinePeriod15m; case KlinePeriod.min30: return l10n.klinePeriod30m; case KlinePeriod.hour1: return l10n.klinePeriod1h; case KlinePeriod.hour4: return l10n.klinePeriod4h; case KlinePeriod.day1: return l10n.klinePeriod1d; case KlinePeriod.week1: return l10n.klinePeriod1w; case KlinePeriod.month1: return l10n.klinePeriod1mon; } } // ── 周期 Tab ────────────────────────────────────────────── // 只 select period,其他字段变化不触发重建 class _PeriodTabBar extends ConsumerWidget { const _PeriodTabBar({required this.marketKey}); final MarketDetailKey marketKey; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final period = ref.watch( marketDetailProvider(marketKey).select((s) => s.period), ); final onChanged = ref.read(marketDetailProvider(marketKey).notifier).setPeriod; // 主栏只展示前4个周期,其余通过"更多"选择 const mainPeriods = [ KlinePeriod.min1, KlinePeriod.min5, KlinePeriod.min15, KlinePeriod.hour1, KlinePeriod.day1 ]; final inMore = !mainPeriods.contains(period); return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( border: Border(bottom: BorderSide(color: cs.outline)), ), child: Row( children: [ ...mainPeriods.map((p) { final selected = p == period; return Semantics( label: 'market_detail_period_${p.wsInterval}', button: true, enabled: true, onTap: () => onChanged(p), child: GestureDetector( onTap: () => onChanged(p), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Text( _klinePeriodLabel(p, l10n), style: TextStyle( fontSize: 13, color: selected ? AppColors.brand : cs.onSurface.withAlpha(153), fontWeight: selected ? FontWeight.w600 : FontWeight.w400, ), ), ), ), ); }), const Spacer(), GestureDetector( onTap: () => _showMorePeriods(context, period, onChanged, l10n), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( inMore ? _klinePeriodLabel(period, l10n) : l10n.more, style: TextStyle( fontSize: 13, color: inMore ? AppColors.brand : cs.onSurface.withAlpha(153), fontWeight: inMore ? FontWeight.w600 : FontWeight.w400, ), ), Icon(Icons.keyboard_arrow_down, size: 14, color: inMore ? AppColors.brand : cs.onSurface.withAlpha(153)), ], ), ), ), ], ), ); } } void _showMorePeriods( BuildContext context, KlinePeriod current, void Function(KlinePeriod) onChanged, AppLocalizations l10n, ) { final cs = Theme.of(context).colorScheme; showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, constraints: const BoxConstraints(maxWidth: double.infinity), backgroundColor: Theme.of(context).colorScheme.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (_) { return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: 12), Center( child: Container( width: 36, height: 4, decoration: BoxDecoration( color: cs.outline, borderRadius: BorderRadius.circular(2), )), ), const SizedBox(height: 16), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Wrap( spacing: 12, runSpacing: 12, children: KlinePeriod.values.map((p) { final selected = p == current; return GestureDetector( onTap: () { onChanged(p); Navigator.of(context).pop(); }, child: Container( padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 10), decoration: BoxDecoration( color: selected ? AppColors.brand.withAlpha(30) : cs.surface, border: Border.all( color: selected ? AppColors.brand : cs.outline, ), borderRadius: BorderRadius.circular(6), ), child: Text( _klinePeriodLabel(p, l10n), style: TextStyle( fontSize: 14, color: selected ? AppColors.brand : cs.onSurface, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, ), ), ), ); }).toList(), ), ), const SizedBox(height: 24), ], ), ); }, ); } // ── K 线图区域(使用 k_chart_plus)────────────────────── class _KlineChartArea extends ConsumerStatefulWidget { const _KlineChartArea({required this.marketKey, this.dismissNotifier}); final MarketDetailKey marketKey; final ValueNotifier? dismissNotifier; @override ConsumerState<_KlineChartArea> createState() => _KlineChartAreaState(); } class _KlineChartAreaState extends ConsumerState<_KlineChartArea> { // 主图指标(MA/BOLL/SAR 可多选)—— 默认只选 MA Set _mainStates = {MainState.MA}; // 副图指标(MACD/KDJ/RSI/WR/CCI 可多选)—— 默认全不选 Set _secondaryStates = {}; bool _volHidden = false; /// 去掉 WS 原始价格字符串的尾零,用于图表右侧标签(不加千分符) static String _stripRawPrice(String s) { final parts = s.split('.'); if (parts.length < 2) return s; final decimal = parts[1].replaceAll(RegExp(r'0+$'), ''); return decimal.isEmpty ? parts[0] : '${parts[0]}.$decimal'; } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final provider = marketDetailProvider(widget.marketKey); final entities = ref.watch(provider.select((s) => s.entities)); final nowPriceStr = ref.watch(provider.select((s) => s.stats?.lastPriceStr)); final notifier = ref.read(provider.notifier); if (entities.isEmpty) { final isLoading = ref.watch(provider.select((s) => s.isLoading)); return SizedBox( height: 350, child: Center( child: isLoading ? const CircularProgressIndicator(strokeWidth: 2) : Text(AppLocalizations.of(context)!.noKlineData, style: TextStyle( color: cs.onSurface.withAlpha(128), fontSize: 14)), ), ); } // 动态计算图表高度:主图 + VOL(20%) + 副图(20%×数量) + 标签(12×数量) const baseH = 260.0; final volH = _volHidden ? 0.0 : baseH * 0.2; final secH = baseH * 0.2 * _secondaryStates.length; final labelH = 12.0 * _mainStates.length; final chartH = baseH + volH + secH + labelH; return Column( mainAxisSize: MainAxisSize.min, children: [ // K 线图(高度随指标数量动态变化) SizedBox( height: chartH, child: Stack( children: [ KChartWidget( entities, _chartStyle, _buildChartColors(cs, isDark), isTrendLine: false, isTapShowInfoDialog: true, dismissInfoNotifier: widget.dismissNotifier, mainStateLi: _mainStates, secondaryStateLi: _secondaryStates, volHidden: _volHidden, showNowPrice: true, maDayList: const [5, 10, 20], mBaseHeight: baseH, fixedLength: 2, nowPriceStr: nowPriceStr != null ? _stripRawPrice(nowPriceStr) : null, timeFormat: const [ yyyy, '-', mm, '-', dd, ' ', HH, ':', nn, ':', ss ], chartTranslations: _buildChartTranslations(context), verticalTextAlignment: VerticalTextAlignment.right, onLoadMore: (isLeft) { if (!isLeft) notifier.loadMoreKlines(); }, isOnDrag: (isDrag) {}, ), // 水印:logo(去黑底)+ 文字,居中半透明叠加 const _KlineWatermark(), ], ), ), // 指标选择器(主图 + 副图) _buildIndicatorBar(), ], ); } /// 指标切换栏:MA BOLL SAR | VOL MACD KDJ RSI WR Widget _buildIndicatorBar() { final cs = Theme.of(context).colorScheme; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( border: Border(top: BorderSide(color: cs.outline)), ), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ // 主图指标 ..._mainIndicators.map((e) => _indicatorChip( label: e.$1, active: _mainStates.contains(e.$2), onTap: () => setState(() { if (_mainStates.contains(e.$2)) { _mainStates = Set.from(_mainStates)..remove(e.$2); } else { _mainStates = Set.from(_mainStates)..add(e.$2); } }), )), // 分隔线 Container( width: 1, height: 16, color: cs.outline, margin: const EdgeInsets.symmetric(horizontal: 6)), // VOL 开关 _indicatorChip( label: 'VOL', active: !_volHidden, onTap: () => setState(() => _volHidden = !_volHidden), ), // 副图指标 ..._secondaryIndicators.map((e) => _indicatorChip( label: e.$1, active: _secondaryStates.contains(e.$2), onTap: () => setState(() { if (_secondaryStates.contains(e.$2)) { _secondaryStates = Set.from(_secondaryStates) ..remove(e.$2); } else { _secondaryStates = Set.from(_secondaryStates)..add(e.$2); } }), )), ], ), ), ); } Widget _indicatorChip({ required String label, required bool active, required VoidCallback onTap, }) { final cs = Theme.of(context).colorScheme; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Container( margin: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: active ? AppColors.brand.withAlpha(30) : Colors.transparent, borderRadius: BorderRadius.circular(4), ), child: Text( label, style: TextStyle( fontSize: 12, color: active ? AppColors.brand : cs.onSurface.withAlpha(153), fontWeight: active ? FontWeight.w600 : FontWeight.w400, ), ), ), ); } static const _mainIndicators = [ ('MA', MainState.MA), ('BOLL', MainState.BOLL), ('SAR', MainState.SAR), ]; static const _secondaryIndicators = [ ('MACD', SecondaryState.MACD), ('KDJ', SecondaryState.KDJ), ('RSI', SecondaryState.RSI), ('WR', SecondaryState.WR), ('CCI', SecondaryState.CCI), ]; // ── 主题感知图表配色 ──────────────────────────────── ChartColors _buildChartColors(ColorScheme cs, bool isDark) => ChartColors( bgColor: isDark ? AppColors.darkBg : AppColors.lightBg, ma5Color: AppColors.chartMa5, ma10Color: AppColors.chartMa10, ma30Color: AppColors.chartMa30, upColor: AppColors.rise, dnColor: AppColors.fall, volColor: AppColors.chartLineBlue, macdColor: AppColors.chartLineBlue, difColor: AppColors.chartMa10, deaColor: AppColors.chartMa30, nowPriceUpColor: AppColors.rise, nowPriceDnColor: AppColors.fall, nowPriceTextColor: Colors.white, gridColor: cs.outline, kLineColor: AppColors.chartLineBlue, selectBorderColor: cs.outline, selectFillColor: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary, defaultTextColor: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary, maxColor: cs.onSurface, minColor: cs.onSurface, infoWindowNormalColor: cs.onSurface, infoWindowTitleColor: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary, infoWindowUpColor: AppColors.rise, infoWindowDnColor: AppColors.fall, hCrossColor: cs.onSurface, vCrossColor: cs.onSurface, crossTextColor: cs.onSurface, ); static final _chartStyle = ChartStyle() ..childPadding = 12.0 // 主图与 VOL/副图之间的间距 ..bottomPadding = 20.0 // 底部留给时间轴标签 ..vCrossWidth = 0.5 // 十字线竖线宽度(默认 8.5 过粗) ..hCrossWidth = 0.5; // 十字线横线宽度 ChartTranslations _buildChartTranslations(BuildContext context) { final l10n = AppLocalizations.of(context)!; return ChartTranslations( date: l10n.klineDate, open: l10n.klineOpen, high: l10n.klineHigh, low: l10n.klineLow, close: l10n.klineClose, changeAmount: l10n.klineChangeAmt, change: l10n.klineChange, amount: l10n.klineAmount, vol: l10n.klineVol, ); } } // ── 订单簿区域 ──────────────────────────────────────────── // 只 select orderBook 和 bottomTab,K线/价格变化不触发订单簿重建 class _OrderBookSection extends ConsumerWidget { const _OrderBookSection({required this.marketKey}); final MarketDetailKey marketKey; List _getTabs(BuildContext context) { final l10n = AppLocalizations.of(context)!; return [l10n.orderBook, l10n.latestTrades, l10n.depthChart]; } @override Widget build(BuildContext context, WidgetRef ref) { final symbol = marketKey.symbol; final isFutures = marketKey.isFutures; final cs = Theme.of(context).colorScheme; final provider = marketDetailProvider(marketKey); final OrderBook? orderBook; int? obPriceDecimals; int obQtyDecimals; if (isFutures) { orderBook = ref.watch(provider.select((s) => s.orderBook)); obPriceDecimals = null; obQtyDecimals = 4; } else { final spot = ref.watch(spotProvider(symbol)); orderBook = spotRawDepthToOrderBook( rawAsks: spot.orderBookAsks, rawBids: spot.orderBookBids, depthPrecision: spot.depth2Pre, ); obPriceDecimals = spot.depth2Pre; obQtyDecimals = spot.volumePrecision; } final tabIndex = ref.watch(provider.select((s) => s.bottomTab)); final onTabChanged = ref.read(provider.notifier).setBottomTab; final tabs = _getTabs(context); return Column( children: [ // Tab 行 Container( decoration: BoxDecoration( border: Border(bottom: BorderSide(color: cs.outline)), ), child: Row( children: List.generate(tabs.length, (i) { final selected = i == tabIndex; return GestureDetector( behavior: HitTestBehavior.opaque, // 整个 padding 区域都响应点击 onTap: () => onTabChanged(i), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 14), child: Text( tabs[i], style: TextStyle( fontSize: 14, color: selected ? cs.onSurface : cs.onSurface.withAlpha(153), fontWeight: selected ? FontWeight.w600 : FontWeight.w400, ), ), ), Container( height: 2, width: 24, color: selected ? AppColors.brand : Colors.transparent, ), ], ), ), ); }), ), ), // 按 tab 切换内容 if (tabIndex == 0) ...[ // ── 订单簿 ──────────────────────────────── _MergedOrderBook( orderBook: orderBook, priceDecimalPlaces: obPriceDecimals, qtyDecimals: obQtyDecimals, ), ] else if (tabIndex == 1) ...[ // ── 最新成交 ────────────────────────────── _RecentTradesPanel(marketKey: marketKey), ] else if (tabIndex == 2) ...[ // ── 深度图 ──────────────────────────────── _DepthChartPanel(marketKey: marketKey), ], ], ); } } // ── 合并订单簿(每行同时显示买盘/卖盘配对)───────────────────── // 原型设计:买入qty | 买价 卖价 | 卖出qty,每行对应同一档位索引 class _MergedOrderBook extends StatelessWidget { const _MergedOrderBook({ required this.orderBook, this.priceDecimalPlaces, this.qtyDecimals = 4, }); final OrderBook? orderBook; /// 现货:与交易对 `depth2Pre` 一致;合约为 null 走 formatPrice 默认规则 final int? priceDecimalPlaces; final int qtyDecimals; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; if (orderBook == null) { return const SizedBox( height: 240, child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ); } final ob = orderBook!; // bids 按价格降序(index 0 = 最优买价),asks 按价格升序(index 0 = 最优卖价) final bids = ob.bids.take(12).toList(); final asks = ob.asks.take(12).toList(); final rowCount = bids.length > asks.length ? bids.length : asks.length; final maxBidAmt = bids.isEmpty ? 1.0 : bids.map((e) => e.amount).fold(0.0, (a, b) => a > b ? a : b); final maxAskAmt = asks.isEmpty ? 1.0 : asks.map((e) => e.amount).fold(0.0, (a, b) => a > b ? a : b); return Column( children: [ // 表头 Padding( padding: const EdgeInsets.fromLTRB(12, 6, 12, 2), child: Row( children: [ Expanded( child: Text(AppLocalizations.of(context)!.buy, style: TextStyle(color: AppColors.rise, fontSize: 10)), ), Expanded( child: Text( AppLocalizations.of(context)!.priceLabel, textAlign: TextAlign.center, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 10), ), ), Expanded( child: Text( AppLocalizations.of(context)!.sell, textAlign: TextAlign.right, style: TextStyle(color: AppColors.fall, fontSize: 10), ), ), ], ), ), // 数据行:买卖配对展示 for (int i = 0; i < rowCount; i++) _PairedRow( bid: i < bids.length ? bids[i] : null, ask: i < asks.length ? asks[i] : null, bidDepth: i < bids.length ? (bids[i].amount / maxBidAmt).clamp(0.0, 1.0) : 0.0, askDepth: i < asks.length ? (asks[i].amount / maxAskAmt).clamp(0.0, 1.0) : 0.0, priceDecimalPlaces: priceDecimalPlaces, qtyDecimals: qtyDecimals, ), ], ); } } class _PairedRow extends StatelessWidget { const _PairedRow({ this.bid, this.ask, required this.bidDepth, required this.askDepth, this.priceDecimalPlaces, this.qtyDecimals = 4, }); final OrderBookEntry? bid; final OrderBookEntry? ask; final double bidDepth; final double askDepth; final int? priceDecimalPlaces; final int qtyDecimals; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return SizedBox( height: 26, child: LayoutBuilder( builder: (_, constraints) { final colW = constraints.maxWidth / 3; final bidBarW = (colW * bidDepth).clamp(0.0, colW); final askBarW = (colW * askDepth).clamp(0.0, colW); return Stack( children: [ // 买盘深度条(左列,绿色) if (bid != null) Positioned( left: 0, top: 0, bottom: 0, child: Container( width: bidBarW, color: const Color(0xFF23D2A1).withAlpha(25), ), ), // 卖盘深度条(右列,红色) if (ask != null) Positioned( right: 0, top: 0, bottom: 0, child: Container( width: askBarW, color: const Color(0xFFFF767B).withAlpha(25), ), ), // 文字内容 Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ // 左列:买入数量(绿色) Expanded( child: Text( bid != null ? formatAmount(bid!.amount, decimals: qtyDecimals) : '', style: const TextStyle( color: AppColors.rise, fontSize: 11, fontFeatures: [FontFeature.tabularFigures()], ), ), ), // 中列:买价 + 卖价(窄屏时用 scaleDown 避免横向溢出) Expanded( child: FittedBox( fit: BoxFit.scaleDown, child: Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ if (bid != null) Text( priceDecimalPlaces != null ? formatPrice(bid!.price, decimalPlaces: priceDecimalPlaces) : formatPrice(bid!.price), style: const TextStyle( color: AppColors.rise, fontSize: 11, fontWeight: FontWeight.w600, fontFeatures: [FontFeature.tabularFigures()], ), ), if (bid != null && ask != null) const SizedBox(width: 4), if (ask != null) Text( priceDecimalPlaces != null ? formatPrice(ask!.price, decimalPlaces: priceDecimalPlaces) : formatPrice(ask!.price), style: const TextStyle( color: AppColors.fall, fontSize: 11, fontWeight: FontWeight.w600, fontFeatures: [FontFeature.tabularFigures()], ), ), ], ), ), ), // 右列:卖出数量(灰色) Expanded( child: Text( ask != null ? formatAmount(ask!.amount, decimals: qtyDecimals) : '', textAlign: TextAlign.right, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11, fontFeatures: const [FontFeature.tabularFigures()], ), ), ), ], ), ), ], ); }, ), ); } } // ── K 线图水印 ──────────────────────────────────────────── class _KlineWatermark extends StatelessWidget { const _KlineWatermark(); @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return IgnorePointer( child: Align( alignment: const Alignment(0, -0.5), child: Opacity( opacity: isDark ? 0.35 : 0.15, child: Image.asset( isDark ? 'assets/images/C2.png' : 'assets/images/C1.png', width: 120, ), ), ), ); } } // ── 深度图面板(使用 k_chart_plus DepthChart)───────────── class _DepthChartPanel extends ConsumerWidget { const _DepthChartPanel({required this.marketKey}); final MarketDetailKey marketKey; /// 买盘预累计量 /// bids 按价格降序传入(index 0 = 最优买价)。 /// 绘制器期望 index 0 在左端(最差价、最大累计量),index last 在右端中间价附近(最优价、最小累计量)。 /// 因此先累计再 reversed:最终 [最差价/最大量, ..., 最优价/最小量] static List _toBidDepth(List bids) { double cum = 0; final result = bids.map((e) { cum += e.amount; return DepthEntity(e.price, cum); }).toList(); return result.reversed.toList(); } /// 卖盘预累计量(价格升序,累计从最低价开始) static List _toAskDepth(List asks) { double cum = 0; return asks.map((e) { cum += e.amount; return DepthEntity(e.price, cum); }).toList(); } @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final isFutures = marketKey.isFutures; final OrderBook? orderBook; var baseUnit = 4; var quoteUnit = 2; if (isFutures) { orderBook = ref.watch( marketDetailProvider(marketKey).select((s) => s.orderBook), ); } else { final spot = ref.watch(spotProvider(marketKey.symbol)); orderBook = spotRawDepthToOrderBook( rawAsks: spot.orderBookAsks, rawBids: spot.orderBookBids, depthPrecision: spot.depth2Pre, ); baseUnit = spot.volumePrecision; quoteUnit = spot.depth2Pre; } if (orderBook == null || orderBook.bids.isEmpty || orderBook.asks.isEmpty) { return SizedBox( height: 230, child: Center( child: Text(AppLocalizations.of(context)!.noDepthData, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)), ), ); } final bids = _toBidDepth(orderBook.bids); final asks = _toAskDepth(orderBook.asks); final depthColors = ChartColors( bgColor: cs.surface, upColor: AppColors.rise, dnColor: AppColors.fall, gridColor: cs.outline, defaultTextColor: cs.onSurface.withAlpha(153), depthBuyColor: AppColors.rise, depthSellColor: AppColors.fall, ); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 图例 Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), child: Row( children: [ _DepthLegendDot(color: AppColors.rise), const SizedBox(width: 4), Text(AppLocalizations.of(context)!.buy, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11)), const SizedBox(width: 16), _DepthLegendDot(color: AppColors.fall), const SizedBox(width: 4), Text(AppLocalizations.of(context)!.sell, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11)), ], ), ), SizedBox( height: 260, child: DepthChart( bids, asks, depthColors, baseUnit: baseUnit, quoteUnit: quoteUnit, ), ), ], ); } } class _DepthLegendDot extends StatelessWidget { const _DepthLegendDot({required this.color}); final Color color; @override Widget build(BuildContext context) { return Container( width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle), ); } } // ── 最新成交面板 ───────────────────────────────────────── /// 按照设计稿:表头(时间 / 价格(USDT) / 数量(BTC))+ 成交列表 /// 通过 .select((s) => s.trades) 独立订阅,订单簿变化不触发此面板重建 class _RecentTradesPanel extends ConsumerWidget { const _RecentTradesPanel({required this.marketKey}); final MarketDetailKey marketKey; @override Widget build(BuildContext context, WidgetRef ref) { final symbol = marketKey.symbol; final isFutures = marketKey.isFutures; final cs = Theme.of(context).colorScheme; final List trades; var qtyDecimals = 2; if (isFutures) { trades = ref.watch( marketDetailProvider(marketKey).select((s) => s.trades), ); } else { final pub = ref.watch( spotProvider(symbol).select((s) => s.recentPublicTrades), ); qtyDecimals = ref.watch( spotProvider(symbol).select((s) => s.volumePrecision), ); trades = pub .map( (t) => RecentTrade( price: t.price, quantity: t.quantity, isBuyerMaker: t.isBuyerMaker, time: t.time, tradeId: t.tradeId, ), ) .toList(); } final base = symbol .replaceAll('USDT', '') .replaceAll('PERP', '') .replaceAll('/', ''); return Column( children: [ // 表头 Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), child: Row( children: [ Expanded( child: Text(AppLocalizations.of(context)!.timeLabel2, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11)), ), Expanded( child: Text(AppLocalizations.of(context)!.priceLabel, textAlign: TextAlign.center, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11)), ), Expanded( child: Text(AppLocalizations.of(context)!.amountLabel2(base), textAlign: TextAlign.right, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11)), ), ], ), ), // 成交列表 if (trades.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 30), child: Center( child: Text(AppLocalizations.of(context)!.noTradeData, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 13)), ), ) else ...trades.take(20).map( (t) => _TradeRow(trade: t, qtyDecimals: qtyDecimals), ), ], ); } } class _TradeRow extends StatelessWidget { const _TradeRow({required this.trade, this.qtyDecimals = 2}); final RecentTrade trade; final int qtyDecimals; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; // 主动卖出(isBuyerMaker=true)显示红色,主动买入显示绿色 final color = trade.isBuyerMaker ? AppColors.fall : AppColors.rise; final time = DateTime.fromMillisecondsSinceEpoch(trade.time); final timeStr = '${time.hour.toString().padLeft(2, '0')}:' '${time.minute.toString().padLeft(2, '0')}:' '${time.second.toString().padLeft(2, '0')}'; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 5), child: Row( children: [ Expanded( child: Text( timeStr, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12), ), ), Expanded( child: Text( formatPrice(trade.price), textAlign: TextAlign.center, style: TextStyle( color: color, fontSize: 12, fontWeight: FontWeight.w500), ), ), Expanded( child: Text( formatAmount(trade.quantity, decimals: qtyDecimals), textAlign: TextAlign.right, style: TextStyle(color: cs.onSurface, fontSize: 12), ), ), ], ), ); } } // ── 底部买入/卖出 或 开多/开空 按钮 ───────────────────────────────────── class _BottomActions extends StatelessWidget { const _BottomActions({required this.marketKey}); final MarketDetailKey marketKey; @override Widget build(BuildContext context) { final symbol = marketKey.symbol; final isFutures = marketKey.isFutures; final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final l10n = AppLocalizations.of(context)!; final buyLabel = isFutures ? l10n.openLong : l10n.buy; final sellLabel = isFutures ? l10n.openShort : l10n.sell; final route = isFutures ? '/futures/$symbol' : '/spot/$symbol'; return Container( padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary, border: Border(top: BorderSide(color: cs.outline)), ), child: Row( children: [ Expanded( child: ElevatedButton( onPressed: () => context.go(route), style: ElevatedButton.styleFrom( backgroundColor: AppColors.rise, foregroundColor: Colors.white, minimumSize: const Size(0, 46), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), elevation: 0, ), child: Text(buyLabel, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600)), ), ), const SizedBox(width: 12), Expanded( child: ElevatedButton( onPressed: () => context.go(route), style: ElevatedButton.styleFrom( backgroundColor: AppColors.fall, foregroundColor: Colors.white, minimumSize: const Size(0, 46), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), elevation: 0, ), child: Text(sellLabel, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600)), ), ), ], ), ); } } // ══════════════════════════════════════════════════════════════ // 概览 Tab(币种信息 + 关键数据) // ══════════════════════════════════════════════════════════════ class _InfoTab extends ConsumerWidget { const _InfoTab({required this.marketKey}); final MarketDetailKey marketKey; String _formatPrice(double? v) { if (v == null) return '--'; // 移除多余尾零,保留有效精度 final s = v.toStringAsFixed(8).replaceAll(RegExp(r'\.?0+$'), ''); return '\$$s'; } @override Widget build(BuildContext context, WidgetRef ref) { final symbol = marketKey.symbol; final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final baseAsset = symbol.toUpperCase().replaceAll('USDT', '').replaceAll('PERP', ''); final coinExt = ref.watch( marketDetailProvider(marketKey).select((s) => s.coinExt), ); // 从 API 数据构建关键数据行(无数据时展示 --) List<(String, String, String?)> buildInfoRows() { final l10n = AppLocalizations.of(context)!; return [ (l10n.rank, coinExt?.rank != null ? 'No.${coinExt!.rank}' : '--', null), (l10n.marketCap, coinExt?.marketCap ?? '--', null), (l10n.circulatingSupply, coinExt?.circulatingSupply ?? '--', null), (l10n.issuePrice, _formatPrice(coinExt?.issuePrice), null), (l10n.athPrice, _formatPrice(coinExt?.athPrice), coinExt?.athDate), ]; } final info = buildInfoRows(); final coinName = coinExt?.nameCn ?? baseAsset; final coinNameEn = coinExt?.nameEn ?? ''; final iconUrl = coinExt?.icon; return SingleChildScrollView( physics: const ClampingScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── 币种信息卡 ────────────────────────────────── Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ // 圆形图标(优先网络图片,失败降级为字母) CoinIcon( symbol: baseAsset, iconUrl: iconUrl ?? '', size: 40, shape: BoxShape.circle, ), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( baseAsset, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700, ), ), if (coinName.isNotEmpty && coinName != baseAsset) ...[ const SizedBox(width: 6), Text( '($coinName)', style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 14, ), ), ], ], ), if (coinNameEn.isNotEmpty) ...[ const SizedBox(height: 2), Text( coinNameEn, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 12, ), ), ], ], ), ], ), ), if (info.isNotEmpty) ...[ const SizedBox(height: 20), // ── 关键数据 ────────────────────────────────── Text( AppLocalizations.of(context)!.keyData, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), Container( decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary, borderRadius: BorderRadius.circular(12), ), child: Column( children: [ for (var i = 0; i < info.length; i++) ...[ if (i > 0) Divider( color: cs.outline, height: 1, indent: 16, endIndent: 16, ), _InfoRow( label: info[i].$1, value: info[i].$2, subValue: info[i].$3, ), ], ], ), ), ], const SizedBox(height: 20), // ── 免责声明 ────────────────────────────────── Center( child: Text( AppLocalizations.of(context)!.dataDisclaimer, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11, ), ), ), const SizedBox(height: 16), ], ), ); } } // ── 关键数据行 ────────────────────────────────────────────── class _InfoRow extends StatelessWidget { const _InfoRow({ required this.label, required this.value, this.subValue, }); final String label; final String value; final String? subValue; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( label, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 14, ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( value, style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w500, ), ), if (subValue != null) Text( subValue!, style: TextStyle( color: cs.onSurface.withAlpha(150), fontSize: 11, ), ), ], ), ], ), ); } } // ══════════════════════════════════════════════════════════════ // 数据 Tab(资金费率图表 + 历史记录) // ══════════════════════════════════════════════════════════════ class _DataTab extends ConsumerWidget { const _DataTab({required this.symbol}); final String symbol; @override Widget build(BuildContext context, WidgetRef ref) { final contractCoinId = ref.watch( futuresProvider(symbol).select((s) => s.contractCoinId), ); if (contractCoinId <= 0) { return const Center(child: CircularProgressIndicator()); } final state = ref.watch(fundingRateProvider(contractCoinId)); final l10n = AppLocalizations.of(context)!; final cs = Theme.of(context).colorScheme; if (state.isLoading) { return const Center(child: CircularProgressIndicator()); } if (state.error != null && state.history.isEmpty) { return Center( child: Text(l10n.loadFailed, style: TextStyle(color: cs.onSurface.withAlpha(153))), ); } final current = state.current; final history = state.history; final ratePct = current != null ? '${current.rate >= 0 ? '+' : ''}${(current.rate * 100).toStringAsFixed(4)}%' : '--'; // 下次结算倒计时(使用 futuresProvider 的 fundingCountdown,已由 WS 驱动) final countdown = ref.watch( futuresProvider(symbol).select((s) => s.fundingCountdown), ); // 去掉 ScrollView 的横向 padding,改为对各区块单独加 padding, // 图表本身不加,实现全宽贴边显示 const hPad = EdgeInsets.symmetric(horizontal: 16); return SingleChildScrollView( physics: const ClampingScrollPhysics(), padding: const EdgeInsets.only(top: 16, bottom: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── 当前资金费率 ────────────────────────────────── Padding( padding: hPad, child: RichText( text: TextSpan( children: [ TextSpan( text: '${l10n.fundingRate}:', style: TextStyle( color: cs.onSurface.withAlpha(180), fontSize: 14, ), ), TextSpan( text: ratePct, style: TextStyle( color: current != null && current.rate >= 0 ? AppColors.rise : AppColors.fall, fontSize: 14, fontWeight: FontWeight.w600, ), ), ], ), ), ), const SizedBox(height: 12), // ── 折线图(全宽,不加横向 padding)──────────────── if (history.isNotEmpty) _FundingRateChart(history: history) else Container( height: 180, alignment: Alignment.center, child: Text(l10n.noData, style: TextStyle(color: cs.onSurface.withAlpha(120))), ), const SizedBox(height: 16), // ── 时间周期 & 倒计时 ──────────────────────────── Padding( padding: hPad, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _infoRow(context, l10n.timePeriod, '8H'), Divider(color: cs.outline.withAlpha(80), height: 1), _infoRow(context, l10n.nextFundingCountdown, countdown), Divider(color: cs.outline, height: 1), const SizedBox(height: 12), // ── 历史表头 ────────────────────────────────────── _HistoryHeader(l10n: l10n), const SizedBox(height: 4), // ── 历史列表 ────────────────────────────────────── if (history.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 24), child: Center( child: Text(l10n.noData, style: TextStyle(color: cs.onSurface.withAlpha(120))), ), ) else ...history.reversed.map((item) => _HistoryRow(item: item)), ], ), ), ], ), ); } Widget _infoRow(BuildContext context, String label, String value) { final cs = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 14)), Text(value, style: TextStyle(color: cs.onSurface, fontSize: 14)), ], ), ); } } // ── 资金费率折线图 ──────────────────────────────────────── class _FundingRateChart extends StatelessWidget { const _FundingRateChart({required this.history}); final List history; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final items = history; if (items.isEmpty) return const SizedBox.shrink(); final rates = items.map((e) => e.rate).toList(); final minRate = rates.reduce((a, b) => a < b ? a : b); final maxRate = rates.reduce((a, b) => a > b ? a : b); final rangePad = (maxRate - minRate) * 0.25; final yMin = minRate - rangePad; final yMax = maxRate + rangePad; final spots = items.asMap().entries.map((e) { return FlSpot(e.key.toDouble(), e.value.rate); }).toList(); // 与 K 线图保持一致的配色 final bgColor = isDark ? AppColors.darkBg : AppColors.lightBg; final gridColor = cs.outline.withAlpha(50); final textColor = isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary; const lineColor = AppColors.chartLineBlue; final tooltipBg = isDark ? AppColors.darkBgSecondary : const Color(0xFF3A3A3C); // x 轴标签间距:最多显示 5 个 final xInterval = (items.length / 5).floorToDouble().clamp(1.0, 999.0); return Container( height: 220, color: bgColor, padding: const EdgeInsets.only(top: 8, bottom: 2, left: 4), child: LineChart( LineChartData( minY: yMin, maxY: yMax, clipData: const FlClipData.all(), gridData: FlGridData( show: true, drawVerticalLine: true, verticalInterval: xInterval, horizontalInterval: (yMax - yMin) / 4, getDrawingHorizontalLine: (_) => FlLine(color: gridColor, strokeWidth: 0.5), getDrawingVerticalLine: (_) => FlLine(color: gridColor, strokeWidth: 0.5), ), borderData: FlBorderData(show: false), titlesData: FlTitlesData( leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 58, getTitlesWidget: (val, meta) { // 只在网格线位置显示,跳过首尾极值 if (val == meta.min || val == meta.max) { return const SizedBox.shrink(); } final pct = (val * 100).toStringAsFixed(3); return Padding( padding: const EdgeInsets.only(left: 4), child: Text('$pct%', style: TextStyle(fontSize: 9, color: textColor)), ); }, ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 18, interval: xInterval, getTitlesWidget: (val, _) { final idx = val.toInt(); if (idx < 0 || idx >= items.length) { return const SizedBox.shrink(); } final date = items[idx].fundingTime; return Text( DateFormat('MM/dd').format(date), style: TextStyle(fontSize: 9, color: textColor), ); }, ), ), ), // 零轴参考线(虚线),与 K 线图十字线风格对齐 extraLinesData: ExtraLinesData( horizontalLines: [ HorizontalLine( y: 0, color: cs.outline.withAlpha(160), strokeWidth: 0.8, dashArray: [4, 4], ), ], ), lineTouchData: LineTouchData( enabled: true, // 十字线:与 K 线图 vCrossWidth / hCrossWidth 对齐 getTouchedSpotIndicator: (_, indices) => indices.map((_) { return TouchedSpotIndicatorData( FlLine(color: cs.onSurface.withAlpha(160), strokeWidth: 0.5), FlDotData( show: true, getDotPainter: (_, __, ___, ____) => FlDotCirclePainter( radius: 3.5, color: lineColor, strokeWidth: 1.5, strokeColor: Colors.white, ), ), ); }).toList(), touchTooltipData: LineTouchTooltipData( getTooltipColor: (_) => tooltipBg, tooltipRoundedRadius: 4, tooltipPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), getTooltipItems: (touchedSpots) { return touchedSpots.map((spot) { final idx = spot.spotIndex; if (idx < 0 || idx >= items.length) return null; final item = items[idx]; final dateStr = DateFormat('MM/dd HH:mm').format(item.fundingTime); final rateStr = '${item.rate >= 0 ? '+' : ''}${(item.rate * 100).toStringAsFixed(4)}%'; final rateColor = item.rate >= 0 ? AppColors.rise : AppColors.fall; return LineTooltipItem( '$dateStr\n', TextStyle( color: isDark ? AppColors.darkTextSecondary : Colors.white70, fontSize: 10), children: [ TextSpan( text: rateStr, style: TextStyle( color: rateColor, fontSize: 11, fontWeight: FontWeight.w600), ), ], ); }).toList(); }, ), touchCallback: (_, __) {}, handleBuiltInTouches: true, ), lineBarsData: [ LineChartBarData( spots: spots, isCurved: true, curveSmoothness: 0.25, color: lineColor, barWidth: 1.5, dotData: const FlDotData(show: false), // 线下渐变填充,与 K 线图折线模式风格一致 belowBarData: BarAreaData( show: true, gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ lineColor.withAlpha(60), lineColor.withAlpha(0), ], ), ), ), ], ), ), ); } } // ── 历史表头 & 行 ───────────────────────────────────────── class _HistoryHeader extends StatelessWidget { const _HistoryHeader({required this.l10n}); final AppLocalizations l10n; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final style = TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12); return Row( children: [ Expanded(child: Text(l10n.timeLabel, style: style)), Expanded( child: Text(l10n.fundingRate, style: style, textAlign: TextAlign.right)), ], ); } } class _HistoryRow extends StatelessWidget { const _HistoryRow({required this.item}); final FundingRateHistoryItem item; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final rateStr = '${item.rate >= 0 ? '+' : ''}${(item.rate * 100).toStringAsFixed(4)}%'; final rateColor = item.rate >= 0 ? AppColors.rise : AppColors.fall; final timeStr = DateFormat('yyyy-MM-dd HH:mm').format(item.fundingTime); return Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Row( children: [ Expanded( child: Text( timeStr, style: TextStyle(color: cs.onSurface, fontSize: 12), ), ), Text( rateStr, style: TextStyle(color: rateColor, fontSize: 12), ), ], ), ); } } // ── 行情详情骨架屏 ───────────────────────────────────────── class _MarketDetailShimmer extends StatelessWidget { const _MarketDetailShimmer(); @override Widget build(BuildContext context) { return AppShimmer( child: SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 行情/概览 Tab 占位 Container( height: 44, padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ shimmerBox(32, 16), const SizedBox(width: 24), shimmerBox(32, 16), ], ), ), // 价格头部 Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(160, 32, radius: 6), const SizedBox(height: 8), Row( children: [ shimmerBox(80, 12), const SizedBox(width: 8), shimmerBox(50, 12), const SizedBox(width: 8), shimmerBox(90, 12), ], ), const SizedBox(height: 12), Row( children: List.generate( 3, (col) => Expanded( child: Column( crossAxisAlignment: col == 2 ? CrossAxisAlignment.end : col == 1 ? CrossAxisAlignment.center : CrossAxisAlignment.start, children: [ shimmerBox(48, 10), const SizedBox(height: 4), shimmerBox(60, 12), const SizedBox(height: 8), shimmerBox(48, 10), const SizedBox(height: 4), shimmerBox(60, 12), ], ), )), ), ], ), ), const SizedBox(height: 4), // 周期 Tab 占位 SizedBox( height: 36, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12), physics: const NeverScrollableScrollPhysics(), itemCount: 7, separatorBuilder: (_, __) => const SizedBox(width: 16), itemBuilder: (_, __) => Center(child: shimmerBox(28, 14)), ), ), const SizedBox(height: 6), // K 线图区域占位 shimmerFill(340, radius: 0), // 指标栏 Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: List.generate( 4, (i) => Padding( padding: const EdgeInsets.only(right: 12), child: shimmerBox(28, 14), )), ), ), const SizedBox(height: 4), // 历史收益率行 Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: List.generate( 6, (_) => Column( children: [ shimmerBox(24, 10), const SizedBox(height: 4), shimmerBox(32, 12), ], )), ), ), const SizedBox(height: 8), // 订单簿 Tab 行 Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( children: [ shimmerBox(48, 14), const SizedBox(width: 24), shimmerBox(48, 14), const SizedBox(width: 24), shimmerBox(36, 14), ], ), ), // 订单簿表头 Padding( padding: const EdgeInsets.fromLTRB(12, 4, 12, 4), child: Row( children: [ Expanded(child: shimmerBox(24, 10)), Expanded(child: Center(child: shimmerBox(56, 10))), Expanded( child: Align( alignment: Alignment.centerRight, child: shimmerBox(24, 10))), ], ), ), // 订单簿数据行 ...List.generate( 10, (_) => Padding( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 5), child: Row( children: [ Expanded(child: shimmerBox(52, 12)), Expanded(child: Center(child: shimmerBox(80, 12))), Expanded( child: Align( alignment: Alignment.centerRight, child: shimmerBox(52, 12))), ], ), )), const SizedBox(height: 16), ], ), ), ); } }