import 'dart:async'; import 'dart:developer' as developer; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/network/dio_client.dart'; import '../data/models/home/market_ticker.dart'; import '../data/services/market_service.dart'; import '../data/services/spot_service.dart'; import 'coin_cache_provider.dart'; import 'spot_ws_provider.dart'; import 'ws_provider.dart'; /// 行情:合约 | 现货 enum MarketMode { futures, spot } // ── Mock 数据(ENABLE_MOCK=true 时使用)────────────────────── // volume24h 为成交额(USDT),与 WS tick.q 口径一致 const _mockTickers = [ MarketTicker( symbol: 'BTCUSDT', baseAsset: 'BTC', lastPrice: 93077.12, change24h: -0.09, volume24h: 11900000000), MarketTicker( symbol: 'ETHUSDT', baseAsset: 'ETH', lastPrice: 3077.12, change24h: 4.81, volume24h: 5630000000), MarketTicker( symbol: 'SOLUSDT', baseAsset: 'SOL', lastPrice: 178.50, change24h: 2.10, volume24h: 2180000000), MarketTicker( symbol: 'BNBUSDT', baseAsset: 'BNB', lastPrice: 621.40, change24h: 0.87, volume24h: 1250000000), MarketTicker( symbol: 'DOGEUSDT', baseAsset: 'DOGE', lastPrice: 0.3842, change24h: 5.23, volume24h: 856000000), MarketTicker( symbol: 'XRPUSDT', baseAsset: 'XRP', lastPrice: 2.1430, change24h: -1.35, volume24h: 732000000), MarketTicker( symbol: 'ADAUSDT', baseAsset: 'ADA', lastPrice: 1.0820, change24h: 3.41, volume24h: 415000000), MarketTicker( symbol: 'AVAXUSDT', baseAsset: 'AVAX', lastPrice: 43.620, change24h: -0.76, volume24h: 389000000), MarketTicker( symbol: 'LTCUSDT', baseAsset: 'LTC', lastPrice: 112.30, change24h: -0.52, volume24h: 274000000), MarketTicker( symbol: 'LINKUSDT', baseAsset: 'LINK', lastPrice: 18.640, change24h: 4.10, volume24h: 198000000), ]; /// 排序字段 enum MarketSortField { volume, price, change } // ── UI State ────────────────────────────────────────────── class MarketState { final bool isLoading; final String? errorMessage; final List tickers; /// 稳定的展示顺序(只在初始加载/手动排序时更新,WS 价格推送不重排) final List displayOrder; final String searchKeyword; final MarketSortField? sortField; final bool sortAsc; final MarketMode mode; final bool spotLoading; final List spotTickers; final List spotDisplayOrder; final MarketSortField? spotSortField; final bool spotSortAsc; const MarketState({ this.isLoading = false, this.errorMessage, this.tickers = const [], this.displayOrder = const [], this.searchKeyword = '', this.sortField, this.sortAsc = true, this.mode = MarketMode.futures, this.spotLoading = false, this.spotTickers = const [], this.spotDisplayOrder = const [], this.spotSortField, this.spotSortAsc = true, }); MarketState copyWith({ bool? isLoading, String? errorMessage, List? tickers, List? displayOrder, String? searchKeyword, MarketSortField? sortField, bool? sortAsc, bool clearSort = false, MarketMode? mode, bool? spotLoading, List? spotTickers, List? spotDisplayOrder, MarketSortField? spotSortField, bool? spotSortAsc, bool clearSpotSort = false, }) { return MarketState( isLoading: isLoading ?? this.isLoading, errorMessage: errorMessage, tickers: tickers ?? this.tickers, displayOrder: displayOrder ?? this.displayOrder, searchKeyword: searchKeyword ?? this.searchKeyword, sortField: clearSort ? null : (sortField ?? this.sortField), sortAsc: sortAsc ?? this.sortAsc, mode: mode ?? this.mode, spotLoading: spotLoading ?? this.spotLoading, spotTickers: spotTickers ?? this.spotTickers, spotDisplayOrder: spotDisplayOrder ?? this.spotDisplayOrder, spotSortField: clearSpotSort ? null : (spotSortField ?? this.spotSortField), spotSortAsc: spotSortAsc ?? this.spotSortAsc, ); } /// 只返回 symbol 列表(变化频率远低于 ticker 数据), /// 用于 ListView 构建行,各行内部自己 select 对应 ticker。 /// 使用 displayOrder 保持稳定顺序,WS 价格更新不改变排列位置。 List get displaySymbols { final order = displayOrder.isEmpty ? tickers.map((t) => t.symbol).toList() : displayOrder; if (searchKeyword.isEmpty) return order; final kw = searchKeyword.toLowerCase(); final tickerMap = {for (final t in tickers) t.symbol: t}; return order.where((sym) { final t = tickerMap[sym]; if (t == null) return false; return t.baseAsset.toLowerCase().contains(kw) || sym.toLowerCase().contains(kw); }).toList(); } List get spotDisplaySymbols { final order = spotDisplayOrder.isEmpty ? spotTickers.map((t) => t.symbol).toList() : spotDisplayOrder; if (searchKeyword.isEmpty) return order; final kw = searchKeyword.toLowerCase(); final tickerMap = {for (final t in spotTickers) t.symbol: t}; return order.where((sym) { final t = tickerMap[sym]; if (t == null) return false; return t.baseAsset.toLowerCase().contains(kw) || sym.toLowerCase().contains(kw); }).toList(); } } // ── Notifier ────────────────────────────────────────────── class MarketNotifier extends Notifier { StreamSubscription? _tickerSub; Timer? _throttleTimer; // 缓冲区:积累 WS 推送的价格更新,由节流定时器批量刷新到 state final _tickerBuffer = {}; StreamSubscription? _spotTickerSub; Timer? _spotThrottleTimer; final _spotTickerBuffer = {}; bool _spotLoaded = false; int _spotTickerLogCount = 0; int _spotTickerSkipLogCount = 0; @override MarketState build() { // Notifier 销毁时清理订阅 ref.onDispose(() { _tickerSub?.cancel(); _throttleTimer?.cancel(); _spotTickerSub?.cancel(); _spotThrottleTimer?.cancel(); }); // 监听 WS 连接状态,重连成功后清空缓冲并重新加载数据 ref.listen>( wsConnectionStateProvider, (prev, next) { final prevState = prev?.valueOrNull; final nextState = next.valueOrNull; if (prevState == WsConnectionState.reconnecting && nextState == WsConnectionState.connected) { _tickerBuffer.clear(); Future.microtask(_load); } }, ); // 现货 WS:connected 时重绑 ticker 监听并补订(新 client / 重连) ref.listen>( spotWsConnectionStateProvider, (prev, next) { final nextState = next.valueOrNull; if (nextState == SpotWsState.connected) { _spotTickerSub?.cancel(); _spotTickerSub = null; if (_spotLoaded && state.spotTickers.isNotEmpty) { _subscribeSpotWsTickers(state.spotTickers); } } }, ); Future.microtask(_load); return const MarketState(isLoading: true); } Future _load() async { List tickers; try { // 优先从全局缓存读取,缓存为空则请求接口 final cache = ref.read(coinCacheProvider); if (cache.isNotEmpty) { tickers = cache.values.toList(); } else { final dio = ref.read(dioClientProvider); final result = await MarketService(dio).getEnabledCoins(); tickers = result.isNotEmpty ? result : _mockTickers; } } catch (e) { tickers = _mockTickers; } // 保留已有的实时价格,避免刷新静态列表时价格闪零 final liveMap = {for (final t in state.tickers) t.symbol: t}; final merged = tickers.map((t) { final live = liveMap[t.symbol]; if (live == null || live.lastPrice == 0) return t; return t.copyWith( lastPrice: live.lastPrice, change24h: live.change24h, volume24h: live.volume24h, ); }).toList(); final order = _computeOrder(merged, state.sortField, state.sortAsc); state = state.copyWith(isLoading: false, tickers: merged, displayOrder: order); // 加载完初始数据后,订阅所有 symbol 的 WS ticker 流 _subscribeWsTickers(tickers); } /// 根据当前排序参数计算稳定展示顺序 List _computeOrder( List tickers, MarketSortField? sortField, bool sortAsc) { final list = tickers.toList(); if (sortField != null) { list.sort((a, b) { final cmp = switch (sortField) { MarketSortField.volume => a.volume24h.compareTo(b.volume24h), MarketSortField.price => a.lastPrice.compareTo(b.lastPrice), MarketSortField.change => a.change24h.compareTo(b.change24h), }; return sortAsc ? cmp : -cmp; }); } return list.map((t) => t.symbol).toList(); } /// 订阅 WS ticker 流,批量订阅所有 symbol 的 market.{symbol}.ticket void _subscribeWsTickers(List tickers) { final ws = ref.read(wsClientProvider); final symbols = tickers.map((t) => t.symbol).toList(); // 差量订阅:自动退订已下架币对,订阅新增币对 ws.resubscribeTickerBatch(symbols); // 仅首次建立监听;刷新时 WS 流不中断,保持数据持续流入 if (_tickerSub == null) { _tickerSub = ws.tickerStream.listen(_onTickerUpdate); } // 仅首次启动节流定时器 if (_throttleTimer == null || !_throttleTimer!.isActive) { _throttleTimer = Timer.periodic( const Duration(milliseconds: 500), (_) => _flushTickerBuffer(), ); } } /// 收到单条 ticker 推送,写入缓冲区(不立即更新 state) void _onTickerUpdate(Map data) { final symbol = data['symbol'] as String?; final price = (data['price'] as num?)?.toDouble(); if (symbol == null || price == null) return; final change24h = (data['change24h'] as num?)?.toDouble() ?? 0; final volume24h = (data['volume24h'] as num?)?.toDouble() ?? 0; final priceStr = data['priceStr'] as String? ?? ''; // 只缓存价格字段,合并时保留原有 icon 等静态信息 _tickerBuffer[symbol] = ( price: price, change24h: change24h, volume24h: volume24h, priceStr: priceStr, ); } /// 节流刷新:将缓冲区中的价格更新合并到 state.tickers(保留 icon 等字段) /// 注意:只更新价格数值,不重新排序 displayOrder,避免列表位置跳动 void _flushTickerBuffer() { if (_tickerBuffer.isEmpty) return; final updated = state.tickers.map((t) { final newer = _tickerBuffer[t.symbol]; if (newer == null) return t; return t.copyWith( lastPrice: newer.price, change24h: newer.change24h, volume24h: newer.volume24h, lastPriceStr: newer.priceStr.isNotEmpty ? newer.priceStr : null, ); }).toList(); _tickerBuffer.clear(); // 只更新 tickers 价格,不触发 displayOrder 变更 state = state.copyWith(tickers: updated); } void setSearch(String keyword) { state = state.copyWith(searchKeyword: keyword); } /// 切换排序:第一次点 → 降序(▼),再点 → 升序(▲),第三次 → 取消 void toggleSort(MarketSortField field) { MarketSortField? newField; bool newAsc; if (state.sortField == field) { if (!state.sortAsc) { // 降序 → 升序 newField = field; newAsc = true; } else { // 升序 → 取消排序(恢复初始顺序) newField = null; newAsc = false; } } else { // 切换到新列,默认降序 newField = field; newAsc = false; } final order = _computeOrder(state.tickers, newField, newAsc); state = state.copyWith( sortField: newField, sortAsc: newAsc, displayOrder: order, clearSort: newField == null, ); } Future refresh() async { if (state.mode == MarketMode.futures) { await ref.read(coinCacheProvider.notifier).refresh(); await _load(); } else { await _loadSpot(); } } void setMode(MarketMode mode) { if (state.mode == mode) return; state = state.copyWith(mode: mode); if (mode == MarketMode.spot && !_spotLoaded) { Future.microtask(_loadSpot); } } void loadSpotIfNeeded() { if (!_spotLoaded) { Future.microtask(_loadSpot); } } Future _loadSpot() async { state = state.copyWith(spotLoading: true); if (!kReleaseMode) { debugPrint('[SpotMarket] _loadSpot start'); developer.log('_loadSpot start', name: 'SpotMarket'); } try { final dio = ref.read(dioClientProvider); final svc = SpotService(dio); final symbols = await svc.getSymbols(); if (!kReleaseMode) { debugPrint('[SpotMarket] getSymbols count=${symbols.length}'); developer.log( 'getSymbols ok, count=${symbols.length} sample=${symbols.isNotEmpty ? symbols.first : {}}', name: 'SpotMarket', ); } // 按后台 sort 字段排序(数值小在前) symbols.sort((a, b) { final sa = (a['sort'] as num?)?.toInt() ?? 9999; final sb = (b['sort'] as num?)?.toInt() ?? 9999; return sa.compareTo(sb); }); final tickers = symbols.map((m) { final sym = (m['symbol'] as String? ?? '').toUpperCase(); final base = (m['base'] as String? ?? '').toUpperCase(); return MarketTicker( symbol: sym, baseAsset: base.isNotEmpty ? base : _deriveBase(sym), lastPrice: 0, change24h: 0, volume24h: 0, isFutures: false, icon: _spotIconUrl(m), ); }).toList(); // 保留已有实时价格 final liveMap = {for (final t in state.spotTickers) t.symbol: t}; final merged = tickers.map((t) { final live = liveMap[t.symbol]; if (live == null || live.lastPrice == 0) return t; return t.copyWith( lastPrice: live.lastPrice, change24h: live.change24h, volume24h: live.volume24h, icon: t.icon.isEmpty && live.icon.isNotEmpty ? live.icon : null, ); }).toList(); state = state.copyWith( spotLoading: false, spotTickers: merged, spotDisplayOrder: merged.map((t) => t.symbol).toList(), ); _spotLoaded = true; _subscribeSpotWsTickers(merged); } catch (e, st) { if (!kReleaseMode) { debugPrint('[SpotMarket] _loadSpot failed: $e'); developer.log( '_loadSpot failed: $e', name: 'SpotMarket', error: e, stackTrace: st, ); } state = state.copyWith(spotLoading: false); } } void _subscribeSpotWsTickers(List tickers) { final ws = ref.read(spotWsClientProvider); final symbols = tickers.map((t) => t.symbol).toList(); if (!kReleaseMode) { debugPrint( '[SpotMarket] subscribe WS tickers: ${symbols.length} → ${symbols.take(3).toList()}'); developer.log( 'subscribe spot tickers: ${symbols.length} symbols → ${symbols.take(5).map((s) => 'market_${s.toLowerCase()}_ticker').join(", ")}…', name: 'SpotMarket', ); } ws.resubscribeTickerBatch(symbols); if (_spotTickerSub == null) { _spotTickerSub = ws.tickerStream.listen(_onSpotTickerUpdate); } if (_spotThrottleTimer == null || !_spotThrottleTimer!.isActive) { _spotThrottleTimer = Timer.periodic( const Duration(milliseconds: 500), (_) => _flushSpotTickerBuffer(), ); } } void _onSpotTickerUpdate(Map data) { final symbol = data['symbol'] as String?; var price = (data['price'] as num?)?.toDouble(); price ??= (data['close'] as num?)?.toDouble(); if (symbol == null || price == null) { if (!kReleaseMode && _spotTickerSkipLogCount < 12) { _spotTickerSkipLogCount++; debugPrint('[SpotMarket] ticker skip (no symbol/price) $data'); developer.log( 'ticker update skipped sym=$symbol price=$price raw=$data', name: 'SpotMarket', ); } return; } if (!kReleaseMode && price <= 0 && _spotTickerLogCount < 3) { debugPrint('[SpotMarket] ticker price<=0 sym=$symbol raw=$data'); } if (!kReleaseMode && _spotTickerLogCount < 8) { _spotTickerLogCount++; developer.log( 'ticker apply sym=$symbol price=$price ch=${data['change24h']} vol=${data['turnover']}', name: 'SpotMarket', ); } _spotTickerBuffer[symbol] = ( price: price, change24h: (data['change24h'] as num?)?.toDouble() ?? 0, volume24h: (data['turnover'] as num?)?.toDouble() ?? 0, priceStr: data['priceStr'] as String? ?? '', ); } void _flushSpotTickerBuffer() { if (_spotTickerBuffer.isEmpty) return; if (!kReleaseMode && _spotTickerBuffer.isNotEmpty) { debugPrint('[SpotMarket] flush buffer keys=${_spotTickerBuffer.keys}'); developer.log( 'flush spot buffer keys=${_spotTickerBuffer.keys.toList()}', name: 'SpotMarket', ); } final updated = state.spotTickers.map((t) { final newer = _spotTickerBuffer[t.symbol]; if (newer == null) return t; return t.copyWith( lastPrice: newer.price, change24h: newer.change24h, volume24h: newer.volume24h, lastPriceStr: newer.priceStr.isNotEmpty ? newer.priceStr : null, ); }).toList(); _spotTickerBuffer.clear(); state = state.copyWith(spotTickers: updated); } void toggleSpotSort(MarketSortField field) { MarketSortField? newField; bool newAsc; if (state.spotSortField == field) { if (!state.spotSortAsc) { newField = field; newAsc = true; } else { newField = null; newAsc = false; } } else { newField = field; newAsc = false; } final sorted = state.spotTickers.toList(); if (newField != null) { sorted.sort((a, b) { final cmp = switch (newField!) { MarketSortField.volume => a.volume24h.compareTo(b.volume24h), MarketSortField.price => a.lastPrice.compareTo(b.lastPrice), MarketSortField.change => a.change24h.compareTo(b.change24h), }; return newAsc ? cmp : -cmp; }); } state = state.copyWith( spotSortField: newField, spotSortAsc: newAsc, spotDisplayOrder: sorted.map((t) => t.symbol).toList(), clearSpotSort: newField == null, ); } static String _spotIconUrl(Map m) { final raw = m['icon']; if (raw == null) return ''; if (raw is String) return raw; return raw.toString(); } static String _deriveBase(String sym) { for (final q in const ['USDT', 'BTC', 'ETH', 'BUSD']) { if (sym.endsWith(q) && sym.length > q.length) { return sym.substring(0, sym.length - q.length); } } return sym; } } final spotTickerProvider = Provider.family((ref, symbol) { return ref.watch(marketProvider.select( (s) => s.spotTickers.cast().firstWhere( (t) => t?.symbol == symbol, orElse: () => null, ), )); }); final marketProvider = NotifierProvider( MarketNotifier.new, ); /// 按 symbol 精确订阅单个 ticker,BTC 价格变化只重建 BTC 那一行。 /// 依赖 MarketTicker 已实现的 == 来判断是否真正变化。 final tickerProvider = Provider.family((ref, symbol) { return ref.watch(marketProvider.select( (s) => s.tickers.cast().firstWhere( (t) => t!.symbol == symbol, orElse: () => null, ), )); });