| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645 |
- 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<MarketTicker> tickers;
- /// 稳定的展示顺序(只在初始加载/手动排序时更新,WS 价格推送不重排)
- final List<String> displayOrder;
- final String searchKeyword;
- final MarketSortField? sortField;
- final bool sortAsc;
- final MarketMode mode;
- final bool spotLoading;
- final List<MarketTicker> spotTickers;
- final List<String> 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<MarketTicker>? tickers,
- List<String>? displayOrder,
- String? searchKeyword,
- MarketSortField? sortField,
- bool? sortAsc,
- bool clearSort = false,
- MarketMode? mode,
- bool? spotLoading,
- List<MarketTicker>? spotTickers,
- List<String>? 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<String> 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<String> 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<MarketState> {
- StreamSubscription? _tickerSub;
- Timer? _throttleTimer;
- // 缓冲区:积累 WS 推送的价格更新,由节流定时器批量刷新到 state
- final _tickerBuffer =
- <String, ({double price, double change24h, double volume24h, String priceStr})>{};
- StreamSubscription? _spotTickerSub;
- Timer? _spotThrottleTimer;
- final _spotTickerBuffer =
- <String, ({double price, double change24h, double volume24h, String priceStr})>{};
- 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<AsyncValue<WsConnectionState>>(
- 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<AsyncValue<SpotWsState>>(
- 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<void> _load() async {
- List<MarketTicker> 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<String> _computeOrder(
- List<MarketTicker> 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<MarketTicker> 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<String, dynamic> 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<void> 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<void> _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 : <String, dynamic>{}}',
- 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<MarketTicker> 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<String, dynamic> 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<String, dynamic> 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<MarketTicker?, String>((ref, symbol) {
- return ref.watch(marketProvider.select(
- (s) => s.spotTickers.cast<MarketTicker?>().firstWhere(
- (t) => t?.symbol == symbol,
- orElse: () => null,
- ),
- ));
- });
- final marketProvider = NotifierProvider<MarketNotifier, MarketState>(
- MarketNotifier.new,
- );
- /// 按 symbol 精确订阅单个 ticker,BTC 价格变化只重建 BTC 那一行。
- /// 依赖 MarketTicker 已实现的 == 来判断是否真正变化。
- final tickerProvider = Provider.family<MarketTicker?, String>((ref, symbol) {
- return ref.watch(marketProvider.select(
- (s) => s.tickers.cast<MarketTicker?>().firstWhere(
- (t) => t!.symbol == symbol,
- orElse: () => null,
- ),
- ));
- });
|