market_provider.dart 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. import 'dart:async';
  2. import 'dart:developer' as developer;
  3. import 'package:flutter/foundation.dart';
  4. import 'package:flutter_riverpod/flutter_riverpod.dart';
  5. import '../core/network/dio_client.dart';
  6. import '../data/models/home/market_ticker.dart';
  7. import '../data/services/market_service.dart';
  8. import '../data/services/spot_service.dart';
  9. import 'coin_cache_provider.dart';
  10. import 'spot_ws_provider.dart';
  11. import 'ws_provider.dart';
  12. /// 行情:合约 | 现货
  13. enum MarketMode { futures, spot }
  14. // ── Mock 数据(ENABLE_MOCK=true 时使用)──────────────────────
  15. // volume24h 为成交额(USDT),与 WS tick.q 口径一致
  16. const _mockTickers = [
  17. MarketTicker(
  18. symbol: 'BTCUSDT',
  19. baseAsset: 'BTC',
  20. lastPrice: 93077.12,
  21. change24h: -0.09,
  22. volume24h: 11900000000),
  23. MarketTicker(
  24. symbol: 'ETHUSDT',
  25. baseAsset: 'ETH',
  26. lastPrice: 3077.12,
  27. change24h: 4.81,
  28. volume24h: 5630000000),
  29. MarketTicker(
  30. symbol: 'SOLUSDT',
  31. baseAsset: 'SOL',
  32. lastPrice: 178.50,
  33. change24h: 2.10,
  34. volume24h: 2180000000),
  35. MarketTicker(
  36. symbol: 'BNBUSDT',
  37. baseAsset: 'BNB',
  38. lastPrice: 621.40,
  39. change24h: 0.87,
  40. volume24h: 1250000000),
  41. MarketTicker(
  42. symbol: 'DOGEUSDT',
  43. baseAsset: 'DOGE',
  44. lastPrice: 0.3842,
  45. change24h: 5.23,
  46. volume24h: 856000000),
  47. MarketTicker(
  48. symbol: 'XRPUSDT',
  49. baseAsset: 'XRP',
  50. lastPrice: 2.1430,
  51. change24h: -1.35,
  52. volume24h: 732000000),
  53. MarketTicker(
  54. symbol: 'ADAUSDT',
  55. baseAsset: 'ADA',
  56. lastPrice: 1.0820,
  57. change24h: 3.41,
  58. volume24h: 415000000),
  59. MarketTicker(
  60. symbol: 'AVAXUSDT',
  61. baseAsset: 'AVAX',
  62. lastPrice: 43.620,
  63. change24h: -0.76,
  64. volume24h: 389000000),
  65. MarketTicker(
  66. symbol: 'LTCUSDT',
  67. baseAsset: 'LTC',
  68. lastPrice: 112.30,
  69. change24h: -0.52,
  70. volume24h: 274000000),
  71. MarketTicker(
  72. symbol: 'LINKUSDT',
  73. baseAsset: 'LINK',
  74. lastPrice: 18.640,
  75. change24h: 4.10,
  76. volume24h: 198000000),
  77. ];
  78. /// 排序字段
  79. enum MarketSortField { volume, price, change }
  80. // ── UI State ──────────────────────────────────────────────
  81. class MarketState {
  82. final bool isLoading;
  83. final String? errorMessage;
  84. final List<MarketTicker> tickers;
  85. /// 稳定的展示顺序(只在初始加载/手动排序时更新,WS 价格推送不重排)
  86. final List<String> displayOrder;
  87. final String searchKeyword;
  88. final MarketSortField? sortField;
  89. final bool sortAsc;
  90. final MarketMode mode;
  91. final bool spotLoading;
  92. final List<MarketTicker> spotTickers;
  93. final List<String> spotDisplayOrder;
  94. final MarketSortField? spotSortField;
  95. final bool spotSortAsc;
  96. const MarketState({
  97. this.isLoading = false,
  98. this.errorMessage,
  99. this.tickers = const [],
  100. this.displayOrder = const [],
  101. this.searchKeyword = '',
  102. this.sortField,
  103. this.sortAsc = true,
  104. this.mode = MarketMode.futures,
  105. this.spotLoading = false,
  106. this.spotTickers = const [],
  107. this.spotDisplayOrder = const [],
  108. this.spotSortField,
  109. this.spotSortAsc = true,
  110. });
  111. MarketState copyWith({
  112. bool? isLoading,
  113. String? errorMessage,
  114. List<MarketTicker>? tickers,
  115. List<String>? displayOrder,
  116. String? searchKeyword,
  117. MarketSortField? sortField,
  118. bool? sortAsc,
  119. bool clearSort = false,
  120. MarketMode? mode,
  121. bool? spotLoading,
  122. List<MarketTicker>? spotTickers,
  123. List<String>? spotDisplayOrder,
  124. MarketSortField? spotSortField,
  125. bool? spotSortAsc,
  126. bool clearSpotSort = false,
  127. }) {
  128. return MarketState(
  129. isLoading: isLoading ?? this.isLoading,
  130. errorMessage: errorMessage,
  131. tickers: tickers ?? this.tickers,
  132. displayOrder: displayOrder ?? this.displayOrder,
  133. searchKeyword: searchKeyword ?? this.searchKeyword,
  134. sortField: clearSort ? null : (sortField ?? this.sortField),
  135. sortAsc: sortAsc ?? this.sortAsc,
  136. mode: mode ?? this.mode,
  137. spotLoading: spotLoading ?? this.spotLoading,
  138. spotTickers: spotTickers ?? this.spotTickers,
  139. spotDisplayOrder: spotDisplayOrder ?? this.spotDisplayOrder,
  140. spotSortField:
  141. clearSpotSort ? null : (spotSortField ?? this.spotSortField),
  142. spotSortAsc: spotSortAsc ?? this.spotSortAsc,
  143. );
  144. }
  145. /// 只返回 symbol 列表(变化频率远低于 ticker 数据),
  146. /// 用于 ListView 构建行,各行内部自己 select 对应 ticker。
  147. /// 使用 displayOrder 保持稳定顺序,WS 价格更新不改变排列位置。
  148. List<String> get displaySymbols {
  149. final order = displayOrder.isEmpty
  150. ? tickers.map((t) => t.symbol).toList()
  151. : displayOrder;
  152. if (searchKeyword.isEmpty) return order;
  153. final kw = searchKeyword.toLowerCase();
  154. final tickerMap = {for (final t in tickers) t.symbol: t};
  155. return order.where((sym) {
  156. final t = tickerMap[sym];
  157. if (t == null) return false;
  158. return t.baseAsset.toLowerCase().contains(kw) ||
  159. sym.toLowerCase().contains(kw);
  160. }).toList();
  161. }
  162. List<String> get spotDisplaySymbols {
  163. final order = spotDisplayOrder.isEmpty
  164. ? spotTickers.map((t) => t.symbol).toList()
  165. : spotDisplayOrder;
  166. if (searchKeyword.isEmpty) return order;
  167. final kw = searchKeyword.toLowerCase();
  168. final tickerMap = {for (final t in spotTickers) t.symbol: t};
  169. return order.where((sym) {
  170. final t = tickerMap[sym];
  171. if (t == null) return false;
  172. return t.baseAsset.toLowerCase().contains(kw) ||
  173. sym.toLowerCase().contains(kw);
  174. }).toList();
  175. }
  176. }
  177. // ── Notifier ──────────────────────────────────────────────
  178. class MarketNotifier extends Notifier<MarketState> {
  179. StreamSubscription? _tickerSub;
  180. Timer? _throttleTimer;
  181. // 缓冲区:积累 WS 推送的价格更新,由节流定时器批量刷新到 state
  182. final _tickerBuffer =
  183. <String, ({double price, double change24h, double volume24h, String priceStr})>{};
  184. StreamSubscription? _spotTickerSub;
  185. Timer? _spotThrottleTimer;
  186. final _spotTickerBuffer =
  187. <String, ({double price, double change24h, double volume24h, String priceStr})>{};
  188. bool _spotLoaded = false;
  189. int _spotTickerLogCount = 0;
  190. int _spotTickerSkipLogCount = 0;
  191. @override
  192. MarketState build() {
  193. // Notifier 销毁时清理订阅
  194. ref.onDispose(() {
  195. _tickerSub?.cancel();
  196. _throttleTimer?.cancel();
  197. _spotTickerSub?.cancel();
  198. _spotThrottleTimer?.cancel();
  199. });
  200. // 监听 WS 连接状态,重连成功后清空缓冲并重新加载数据
  201. ref.listen<AsyncValue<WsConnectionState>>(
  202. wsConnectionStateProvider,
  203. (prev, next) {
  204. final prevState = prev?.valueOrNull;
  205. final nextState = next.valueOrNull;
  206. if (prevState == WsConnectionState.reconnecting &&
  207. nextState == WsConnectionState.connected) {
  208. _tickerBuffer.clear();
  209. Future.microtask(_load);
  210. }
  211. },
  212. );
  213. // 现货 WS:connected 时重绑 ticker 监听并补订(新 client / 重连)
  214. ref.listen<AsyncValue<SpotWsState>>(
  215. spotWsConnectionStateProvider,
  216. (prev, next) {
  217. final nextState = next.valueOrNull;
  218. if (nextState == SpotWsState.connected) {
  219. _spotTickerSub?.cancel();
  220. _spotTickerSub = null;
  221. if (_spotLoaded && state.spotTickers.isNotEmpty) {
  222. _subscribeSpotWsTickers(state.spotTickers);
  223. }
  224. }
  225. },
  226. );
  227. Future.microtask(_load);
  228. return const MarketState(isLoading: true);
  229. }
  230. Future<void> _load() async {
  231. List<MarketTicker> tickers;
  232. try {
  233. // 优先从全局缓存读取,缓存为空则请求接口
  234. final cache = ref.read(coinCacheProvider);
  235. if (cache.isNotEmpty) {
  236. tickers = cache.values.toList();
  237. } else {
  238. final dio = ref.read(dioClientProvider);
  239. final result = await MarketService(dio).getEnabledCoins();
  240. tickers = result.isNotEmpty ? result : _mockTickers;
  241. }
  242. } catch (e) {
  243. tickers = _mockTickers;
  244. }
  245. // 保留已有的实时价格,避免刷新静态列表时价格闪零
  246. final liveMap = {for (final t in state.tickers) t.symbol: t};
  247. final merged = tickers.map((t) {
  248. final live = liveMap[t.symbol];
  249. if (live == null || live.lastPrice == 0) return t;
  250. return t.copyWith(
  251. lastPrice: live.lastPrice,
  252. change24h: live.change24h,
  253. volume24h: live.volume24h,
  254. );
  255. }).toList();
  256. final order = _computeOrder(merged, state.sortField, state.sortAsc);
  257. state =
  258. state.copyWith(isLoading: false, tickers: merged, displayOrder: order);
  259. // 加载完初始数据后,订阅所有 symbol 的 WS ticker 流
  260. _subscribeWsTickers(tickers);
  261. }
  262. /// 根据当前排序参数计算稳定展示顺序
  263. List<String> _computeOrder(
  264. List<MarketTicker> tickers, MarketSortField? sortField, bool sortAsc) {
  265. final list = tickers.toList();
  266. if (sortField != null) {
  267. list.sort((a, b) {
  268. final cmp = switch (sortField) {
  269. MarketSortField.volume => a.volume24h.compareTo(b.volume24h),
  270. MarketSortField.price => a.lastPrice.compareTo(b.lastPrice),
  271. MarketSortField.change => a.change24h.compareTo(b.change24h),
  272. };
  273. return sortAsc ? cmp : -cmp;
  274. });
  275. }
  276. return list.map((t) => t.symbol).toList();
  277. }
  278. /// 订阅 WS ticker 流,批量订阅所有 symbol 的 market.{symbol}.ticket
  279. void _subscribeWsTickers(List<MarketTicker> tickers) {
  280. final ws = ref.read(wsClientProvider);
  281. final symbols = tickers.map((t) => t.symbol).toList();
  282. // 差量订阅:自动退订已下架币对,订阅新增币对
  283. ws.resubscribeTickerBatch(symbols);
  284. // 仅首次建立监听;刷新时 WS 流不中断,保持数据持续流入
  285. if (_tickerSub == null) {
  286. _tickerSub = ws.tickerStream.listen(_onTickerUpdate);
  287. }
  288. // 仅首次启动节流定时器
  289. if (_throttleTimer == null || !_throttleTimer!.isActive) {
  290. _throttleTimer = Timer.periodic(
  291. const Duration(milliseconds: 500),
  292. (_) => _flushTickerBuffer(),
  293. );
  294. }
  295. }
  296. /// 收到单条 ticker 推送,写入缓冲区(不立即更新 state)
  297. void _onTickerUpdate(Map<String, dynamic> data) {
  298. final symbol = data['symbol'] as String?;
  299. final price = (data['price'] as num?)?.toDouble();
  300. if (symbol == null || price == null) return;
  301. final change24h = (data['change24h'] as num?)?.toDouble() ?? 0;
  302. final volume24h = (data['volume24h'] as num?)?.toDouble() ?? 0;
  303. final priceStr = data['priceStr'] as String? ?? '';
  304. // 只缓存价格字段,合并时保留原有 icon 等静态信息
  305. _tickerBuffer[symbol] = (
  306. price: price,
  307. change24h: change24h,
  308. volume24h: volume24h,
  309. priceStr: priceStr,
  310. );
  311. }
  312. /// 节流刷新:将缓冲区中的价格更新合并到 state.tickers(保留 icon 等字段)
  313. /// 注意:只更新价格数值,不重新排序 displayOrder,避免列表位置跳动
  314. void _flushTickerBuffer() {
  315. if (_tickerBuffer.isEmpty) return;
  316. final updated = state.tickers.map((t) {
  317. final newer = _tickerBuffer[t.symbol];
  318. if (newer == null) return t;
  319. return t.copyWith(
  320. lastPrice: newer.price,
  321. change24h: newer.change24h,
  322. volume24h: newer.volume24h,
  323. lastPriceStr: newer.priceStr.isNotEmpty ? newer.priceStr : null,
  324. );
  325. }).toList();
  326. _tickerBuffer.clear();
  327. // 只更新 tickers 价格,不触发 displayOrder 变更
  328. state = state.copyWith(tickers: updated);
  329. }
  330. void setSearch(String keyword) {
  331. state = state.copyWith(searchKeyword: keyword);
  332. }
  333. /// 切换排序:第一次点 → 降序(▼),再点 → 升序(▲),第三次 → 取消
  334. void toggleSort(MarketSortField field) {
  335. MarketSortField? newField;
  336. bool newAsc;
  337. if (state.sortField == field) {
  338. if (!state.sortAsc) {
  339. // 降序 → 升序
  340. newField = field;
  341. newAsc = true;
  342. } else {
  343. // 升序 → 取消排序(恢复初始顺序)
  344. newField = null;
  345. newAsc = false;
  346. }
  347. } else {
  348. // 切换到新列,默认降序
  349. newField = field;
  350. newAsc = false;
  351. }
  352. final order = _computeOrder(state.tickers, newField, newAsc);
  353. state = state.copyWith(
  354. sortField: newField,
  355. sortAsc: newAsc,
  356. displayOrder: order,
  357. clearSort: newField == null,
  358. );
  359. }
  360. Future<void> refresh() async {
  361. if (state.mode == MarketMode.futures) {
  362. await ref.read(coinCacheProvider.notifier).refresh();
  363. await _load();
  364. } else {
  365. await _loadSpot();
  366. }
  367. }
  368. void setMode(MarketMode mode) {
  369. if (state.mode == mode) return;
  370. state = state.copyWith(mode: mode);
  371. if (mode == MarketMode.spot && !_spotLoaded) {
  372. Future.microtask(_loadSpot);
  373. }
  374. }
  375. void loadSpotIfNeeded() {
  376. if (!_spotLoaded) {
  377. Future.microtask(_loadSpot);
  378. }
  379. }
  380. Future<void> _loadSpot() async {
  381. state = state.copyWith(spotLoading: true);
  382. if (!kReleaseMode) {
  383. debugPrint('[SpotMarket] _loadSpot start');
  384. developer.log('_loadSpot start', name: 'SpotMarket');
  385. }
  386. try {
  387. final dio = ref.read(dioClientProvider);
  388. final svc = SpotService(dio);
  389. final symbols = await svc.getSymbols();
  390. if (!kReleaseMode) {
  391. debugPrint('[SpotMarket] getSymbols count=${symbols.length}');
  392. developer.log(
  393. 'getSymbols ok, count=${symbols.length} sample=${symbols.isNotEmpty ? symbols.first : <String, dynamic>{}}',
  394. name: 'SpotMarket',
  395. );
  396. }
  397. // 按后台 sort 字段排序(数值小在前)
  398. symbols.sort((a, b) {
  399. final sa = (a['sort'] as num?)?.toInt() ?? 9999;
  400. final sb = (b['sort'] as num?)?.toInt() ?? 9999;
  401. return sa.compareTo(sb);
  402. });
  403. final tickers = symbols.map((m) {
  404. final sym = (m['symbol'] as String? ?? '').toUpperCase();
  405. final base = (m['base'] as String? ?? '').toUpperCase();
  406. return MarketTicker(
  407. symbol: sym,
  408. baseAsset: base.isNotEmpty ? base : _deriveBase(sym),
  409. lastPrice: 0,
  410. change24h: 0,
  411. volume24h: 0,
  412. isFutures: false,
  413. icon: _spotIconUrl(m),
  414. );
  415. }).toList();
  416. // 保留已有实时价格
  417. final liveMap = {for (final t in state.spotTickers) t.symbol: t};
  418. final merged = tickers.map((t) {
  419. final live = liveMap[t.symbol];
  420. if (live == null || live.lastPrice == 0) return t;
  421. return t.copyWith(
  422. lastPrice: live.lastPrice,
  423. change24h: live.change24h,
  424. volume24h: live.volume24h,
  425. icon: t.icon.isEmpty && live.icon.isNotEmpty ? live.icon : null,
  426. );
  427. }).toList();
  428. state = state.copyWith(
  429. spotLoading: false,
  430. spotTickers: merged,
  431. spotDisplayOrder: merged.map((t) => t.symbol).toList(),
  432. );
  433. _spotLoaded = true;
  434. _subscribeSpotWsTickers(merged);
  435. } catch (e, st) {
  436. if (!kReleaseMode) {
  437. debugPrint('[SpotMarket] _loadSpot failed: $e');
  438. developer.log(
  439. '_loadSpot failed: $e',
  440. name: 'SpotMarket',
  441. error: e,
  442. stackTrace: st,
  443. );
  444. }
  445. state = state.copyWith(spotLoading: false);
  446. }
  447. }
  448. void _subscribeSpotWsTickers(List<MarketTicker> tickers) {
  449. final ws = ref.read(spotWsClientProvider);
  450. final symbols = tickers.map((t) => t.symbol).toList();
  451. if (!kReleaseMode) {
  452. debugPrint(
  453. '[SpotMarket] subscribe WS tickers: ${symbols.length} → ${symbols.take(3).toList()}');
  454. developer.log(
  455. 'subscribe spot tickers: ${symbols.length} symbols → ${symbols.take(5).map((s) => 'market_${s.toLowerCase()}_ticker').join(", ")}…',
  456. name: 'SpotMarket',
  457. );
  458. }
  459. ws.resubscribeTickerBatch(symbols);
  460. if (_spotTickerSub == null) {
  461. _spotTickerSub = ws.tickerStream.listen(_onSpotTickerUpdate);
  462. }
  463. if (_spotThrottleTimer == null || !_spotThrottleTimer!.isActive) {
  464. _spotThrottleTimer = Timer.periodic(
  465. const Duration(milliseconds: 500),
  466. (_) => _flushSpotTickerBuffer(),
  467. );
  468. }
  469. }
  470. void _onSpotTickerUpdate(Map<String, dynamic> data) {
  471. final symbol = data['symbol'] as String?;
  472. var price = (data['price'] as num?)?.toDouble();
  473. price ??= (data['close'] as num?)?.toDouble();
  474. if (symbol == null || price == null) {
  475. if (!kReleaseMode && _spotTickerSkipLogCount < 12) {
  476. _spotTickerSkipLogCount++;
  477. debugPrint('[SpotMarket] ticker skip (no symbol/price) $data');
  478. developer.log(
  479. 'ticker update skipped sym=$symbol price=$price raw=$data',
  480. name: 'SpotMarket',
  481. );
  482. }
  483. return;
  484. }
  485. if (!kReleaseMode && price <= 0 && _spotTickerLogCount < 3) {
  486. debugPrint('[SpotMarket] ticker price<=0 sym=$symbol raw=$data');
  487. }
  488. if (!kReleaseMode && _spotTickerLogCount < 8) {
  489. _spotTickerLogCount++;
  490. developer.log(
  491. 'ticker apply sym=$symbol price=$price ch=${data['change24h']} vol=${data['turnover']}',
  492. name: 'SpotMarket',
  493. );
  494. }
  495. _spotTickerBuffer[symbol] = (
  496. price: price,
  497. change24h: (data['change24h'] as num?)?.toDouble() ?? 0,
  498. volume24h: (data['turnover'] as num?)?.toDouble() ?? 0,
  499. priceStr: data['priceStr'] as String? ?? '',
  500. );
  501. }
  502. void _flushSpotTickerBuffer() {
  503. if (_spotTickerBuffer.isEmpty) return;
  504. if (!kReleaseMode && _spotTickerBuffer.isNotEmpty) {
  505. debugPrint('[SpotMarket] flush buffer keys=${_spotTickerBuffer.keys}');
  506. developer.log(
  507. 'flush spot buffer keys=${_spotTickerBuffer.keys.toList()}',
  508. name: 'SpotMarket',
  509. );
  510. }
  511. final updated = state.spotTickers.map((t) {
  512. final newer = _spotTickerBuffer[t.symbol];
  513. if (newer == null) return t;
  514. return t.copyWith(
  515. lastPrice: newer.price,
  516. change24h: newer.change24h,
  517. volume24h: newer.volume24h,
  518. lastPriceStr: newer.priceStr.isNotEmpty ? newer.priceStr : null,
  519. );
  520. }).toList();
  521. _spotTickerBuffer.clear();
  522. state = state.copyWith(spotTickers: updated);
  523. }
  524. void toggleSpotSort(MarketSortField field) {
  525. MarketSortField? newField;
  526. bool newAsc;
  527. if (state.spotSortField == field) {
  528. if (!state.spotSortAsc) {
  529. newField = field;
  530. newAsc = true;
  531. } else {
  532. newField = null;
  533. newAsc = false;
  534. }
  535. } else {
  536. newField = field;
  537. newAsc = false;
  538. }
  539. final sorted = state.spotTickers.toList();
  540. if (newField != null) {
  541. sorted.sort((a, b) {
  542. final cmp = switch (newField!) {
  543. MarketSortField.volume => a.volume24h.compareTo(b.volume24h),
  544. MarketSortField.price => a.lastPrice.compareTo(b.lastPrice),
  545. MarketSortField.change => a.change24h.compareTo(b.change24h),
  546. };
  547. return newAsc ? cmp : -cmp;
  548. });
  549. }
  550. state = state.copyWith(
  551. spotSortField: newField,
  552. spotSortAsc: newAsc,
  553. spotDisplayOrder: sorted.map((t) => t.symbol).toList(),
  554. clearSpotSort: newField == null,
  555. );
  556. }
  557. static String _spotIconUrl(Map<String, dynamic> m) {
  558. final raw = m['icon'];
  559. if (raw == null) return '';
  560. if (raw is String) return raw;
  561. return raw.toString();
  562. }
  563. static String _deriveBase(String sym) {
  564. for (final q in const ['USDT', 'BTC', 'ETH', 'BUSD']) {
  565. if (sym.endsWith(q) && sym.length > q.length) {
  566. return sym.substring(0, sym.length - q.length);
  567. }
  568. }
  569. return sym;
  570. }
  571. }
  572. final spotTickerProvider =
  573. Provider.family<MarketTicker?, String>((ref, symbol) {
  574. return ref.watch(marketProvider.select(
  575. (s) => s.spotTickers.cast<MarketTicker?>().firstWhere(
  576. (t) => t?.symbol == symbol,
  577. orElse: () => null,
  578. ),
  579. ));
  580. });
  581. final marketProvider = NotifierProvider<MarketNotifier, MarketState>(
  582. MarketNotifier.new,
  583. );
  584. /// 按 symbol 精确订阅单个 ticker,BTC 价格变化只重建 BTC 那一行。
  585. /// 依赖 MarketTicker 已实现的 == 来判断是否真正变化。
  586. final tickerProvider = Provider.family<MarketTicker?, String>((ref, symbol) {
  587. return ref.watch(marketProvider.select(
  588. (s) => s.tickers.cast<MarketTicker?>().firstWhere(
  589. (t) => t!.symbol == symbol,
  590. orElse: () => null,
  591. ),
  592. ));
  593. });