import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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 '../../../data/models/home/market_ticker.dart'; import '../../../providers/market_provider.dart'; /// 交易对选择器中的 Tab 类型 enum SymbolPickerTab { futures, spot } /// 通用币对选择底部弹窗(合约 / 现货双 Tab) /// /// 数据直接来源于 [marketProvider](已批量订阅所有 WS ticker), /// 打开时无需额外 API 请求,实时价格自动刷新。 /// /// 用法: /// ```dart /// showModalBottomSheet( /// isScrollControlled: true, /// builder: (_) => SymbolPickerSheet( /// currentSymbol: 'BTCUSDT', /// initialTab: SymbolPickerTab.spot, /// onSelected: (sym) { /* 合约选中 */ }, /// onSpotSelected: (sym) { /* 现货选中 */ }, /// ), /// ); /// ``` class SymbolPickerSheet extends ConsumerStatefulWidget { const SymbolPickerSheet({ super.key, required this.onSelected, this.onSpotSelected, this.currentSymbol, this.initialTab = SymbolPickerTab.futures, this.visibleTabs, this.title, }); /// 当前选中的 symbol(大写无斜线,如 "BTCUSDT"),用于高亮当前行 final String? currentSymbol; /// 合约 tab 选中回调,返回大写无斜线格式的 symbol final ValueChanged onSelected; /// 现货 tab 选中回调;若为 null 则复用 [onSelected] final ValueChanged? onSpotSelected; /// 初始展示的 Tab,默认合约 final SymbolPickerTab initialTab; /// 允许展示的 Tab 列表;null 或空表示全部展示 final List? visibleTabs; /// 弹窗标题(已废弃,Tab 本身即标题,保留为可选兼容参数) final String? title; @override ConsumerState createState() => _SymbolPickerSheetState(); } class _SymbolPickerSheetState extends ConsumerState { final _searchCtrl = TextEditingController(); String _query = ''; late SymbolPickerTab _tab; // 本地 ticker 快照,通过节流 setState 刷新,保证所有 Riverpod // 读取发生在 build 阶段(而非 layout 阶段),避免 layout/semantics 断言崩溃。 Map _futuresTickerBySymbol = {}; Map _spotTickerBySymbol = {}; bool _pendingRefresh = false; @override void initState() { super.initState(); _tab = widget.initialTab; WidgetsBinding.instance.addPostFrameCallback((_) { _syncSnapshot(); // 预加载现货数据 if (_tab == SymbolPickerTab.spot) { ref.read(marketProvider.notifier).loadSpotIfNeeded(); } }); } void _syncSnapshot() { if (!mounted) return; final mkt = ref.read(marketProvider); setState(() { _futuresTickerBySymbol = {for (final t in mkt.tickers) t.symbol: t}; _spotTickerBySymbol = {for (final t in mkt.spotTickers) t.symbol: t}; }); } void _scheduleRefresh() { if (_pendingRefresh) return; _pendingRefresh = true; WidgetsBinding.instance.addPostFrameCallback((_) { _pendingRefresh = false; _syncSnapshot(); }); } @override void dispose() { _searchCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; // 只 watch displayOrder(币对列表结构),价格更新不触发重建 final futuresSymbols = ref.watch( marketProvider.select((s) => s.displayOrder), ); final spotSymbols = ref.watch( marketProvider.select((s) => s.spotDisplayOrder), ); // 监听价格变化,节流刷新本地快照 ref.listen>( marketProvider.select((s) => s.tickers), (_, __) => _scheduleRefresh(), ); ref.listen>( marketProvider.select((s) => s.spotTickers), (_, __) => _scheduleRefresh(), ); // 当前 tab 的数据 final allSymbols = _tab == SymbolPickerTab.futures ? futuresSymbols : spotSymbols; final tickerBySymbol = _tab == SymbolPickerTab.futures ? _futuresTickerBySymbol : _spotTickerBySymbol; List filtered; if (_query.isEmpty) { filtered = allSymbols; } else { final q = _query.toLowerCase(); filtered = allSymbols.where((sym) { final t = tickerBySymbol[sym]; return sym.toLowerCase().contains(q) || (t?.baseAsset.toLowerCase().contains(q) ?? false); }).toList(); } return DraggableScrollableSheet( initialChildSize: 0.85, minChildSize: 0.5, maxChildSize: 0.95, expand: false, builder: (_, scrollCtrl) => Column( children: [ // ── 标题栏 ─────────────────────────────────────── Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), child: Row( children: [ Text( l10n.selectTradingPair, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w600, ), ), const Spacer(), GestureDetector( onTap: () => Navigator.pop(context), child: Icon( Icons.close, color: cs.onSurface.withAlpha(153), size: 20, ), ), ], ), ), // ── 合约 / 现货 Tab 切换 ──────────────────────── _TabBar( current: _tab, visibleTabs: widget.visibleTabs, onChanged: (t) => setState(() { _tab = t; _query = ''; _searchCtrl.clear(); }), ), // ── 搜索框 ─────────────────────────────────────── Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Container( height: 36, decoration: BoxDecoration( color: cs.surfaceContainerHighest.withAlpha(80), borderRadius: BorderRadius.circular(8), ), child: TextField( controller: _searchCtrl, style: TextStyle(color: cs.onSurface, fontSize: 14), onChanged: (v) => setState(() => _query = v.trim()), decoration: InputDecoration( hintText: l10n.searchHint, hintStyle: TextStyle( color: cs.onSurface.withAlpha(100), fontSize: 14), prefixIcon: Icon( Icons.search, color: cs.onSurface.withAlpha(100), size: 18, ), border: InputBorder.none, contentPadding: const EdgeInsets.symmetric(vertical: 8), ), ), ), ), // ── 列表头 ─────────────────────────────────────── Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Row( children: [ Expanded( child: Text( l10n.nameAndVolume, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11, ), ), ), Text( l10n.latestPriceChange, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11, ), ), ], ), ), const Divider(height: 1), // ── 币对列表 ───────────────────────────────────── Expanded( child: allSymbols.isEmpty ? Center( child: _tab == SymbolPickerTab.spot ? Text( l10n.noOrders, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 14, ), ) : const CircularProgressIndicator(), ) : ListView.builder( controller: scrollCtrl, itemCount: filtered.length, itemBuilder: (ctx, i) { final sym = filtered[i]; final ticker = tickerBySymbol[sym]; if (ticker == null) return const SizedBox.shrink(); return _TickerRow( ticker: ticker, isSelected: sym == widget.currentSymbol, isSpot: _tab == SymbolPickerTab.spot, onTap: () { if (_tab == SymbolPickerTab.spot) { (widget.onSpotSelected ?? widget.onSelected)(sym); } else { widget.onSelected(sym); } }, ); }, ), ), ], ), ); } } // ── Tab 切换栏 ──────────────────────────────────────────── class _TabBar extends StatelessWidget { const _TabBar({ required this.current, required this.onChanged, this.visibleTabs, }); final SymbolPickerTab current; final ValueChanged onChanged; final List? visibleTabs; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; Widget tab(String label, SymbolPickerTab value) { final active = current == 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( fontSize: 14, fontWeight: active ? FontWeight.w600 : FontWeight.w400, color: active ? cs.onSurface : cs.onSurface.withAlpha(140), ), ), ), ), ); } final tabs = visibleTabs != null && visibleTabs!.isNotEmpty ? visibleTabs! : const [SymbolPickerTab.futures, SymbolPickerTab.spot]; if (tabs.length <= 1) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: tabs.map((t) { final label = t == SymbolPickerTab.futures ? l10n.perpetualFutures : l10n.spotTab; return tab(label, t); }).toList(), ), ); } } // ── 单行 Widget(纯 StatelessWidget,无 Riverpod 订阅)────────── class _TickerRow extends StatelessWidget { const _TickerRow({ required this.ticker, required this.isSelected, required this.isSpot, required this.onTap, }); final MarketTicker ticker; final bool isSelected; final bool isSpot; final VoidCallback onTap; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final isUp = ticker.change24h >= 0; final changeColor = AppColors.changeColor(ticker.change24h); return Material( color: Colors.transparent, child: InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( children: [ // ── 左侧:图标 + 币对名 + 成交量 ────────────── Expanded( child: Row( children: [ _CoinIcon(icon: ticker.icon, baseAsset: ticker.baseAsset), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: Text( formatUsdtPairDisplay(ticker.symbol), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: isSelected ? AppColors.brand : cs.onSurface, fontSize: 13, fontWeight: FontWeight.w600, ), ), ), const SizedBox(width: 4), Container( padding: const EdgeInsets.symmetric( horizontal: 4, vertical: 1), decoration: BoxDecoration( color: cs.outline.withAlpha(40), borderRadius: BorderRadius.circular(3), ), child: Text( isSpot ? l10n.spotTab : l10n.perpetual, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 9, ), ), ), if (isSelected) Padding( padding: const EdgeInsets.only(left: 6), child: Icon(Icons.check, size: 14, color: AppColors.brand), ), ], ), const SizedBox(height: 2), Text( ticker.volume24h > 0 ? l10n.volumeWithValue( formatVolume(ticker.volume24h)) : l10n.volumeEmpty, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 10, ), ), ], ), ), ], ), ), // ── 右侧:价格 + 涨跌幅 ─────────────────────── Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( ticker.lastPrice > 0 ? (ticker.lastPriceStr != null ? formatRawPrice(ticker.lastPriceStr!) : formatPrice(ticker.lastPrice)) : '--', style: TextStyle( color: ticker.lastPrice > 0 ? changeColor : cs.onSurface.withAlpha(153), fontSize: 13, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( ticker.lastPrice > 0 ? '${isUp ? '+' : ''}${ticker.change24h.toStringAsFixed(2)}%' : '--', style: TextStyle( color: ticker.lastPrice > 0 ? changeColor : cs.onSurface.withAlpha(100), fontSize: 10, ), ), ], ), ], ), ), ), ); } } // ── 圆形图标(网络图片 / 首字母回退)────────────────────────── class _CoinIcon extends StatelessWidget { const _CoinIcon({required this.icon, required this.baseAsset}); final String icon; final String baseAsset; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Container( width: 32, height: 32, decoration: BoxDecoration( color: cs.surfaceContainerHighest.withAlpha(80), shape: BoxShape.circle, ), child: ClipOval( child: icon.isNotEmpty ? CachedNetworkImage( imageUrl: icon, width: 32, height: 32, fit: BoxFit.cover, placeholder: (_, __) => _Fallback(baseAsset: baseAsset), errorWidget: (_, __, ___) => _Fallback(baseAsset: baseAsset), ) : _Fallback(baseAsset: baseAsset), ), ); } } class _Fallback extends StatelessWidget { const _Fallback({required this.baseAsset}); final String baseAsset; @override Widget build(BuildContext context) { return Center( child: Text( baseAsset.isNotEmpty ? baseAsset[0] : '?', style: TextStyle( color: AppColors.brand, fontWeight: FontWeight.w700, fontSize: 13, ), ), ); } }