home_provider.dart 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. import 'dart:async';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import '../core/network/dio_client.dart';
  4. import '../data/models/home/app_header_item.dart';
  5. import '../data/models/home/market_ticker.dart';
  6. import '../data/models/home/activity_banner.dart';
  7. import '../data/services/market_service.dart';
  8. import 'asset_provider.dart';
  9. import 'auth_provider.dart';
  10. import 'coin_cache_provider.dart';
  11. import 'top_trader_provider.dart';
  12. import 'ws_provider.dart';
  13. // ── UI State ──────────────────────────────────────────────
  14. class HomeState {
  15. final bool isLoggedIn;
  16. final bool isLoading;
  17. final String? errorMessage;
  18. final double totalAsset;
  19. final double todayPnl;
  20. final double todayPnlPct;
  21. /// false 表示后端收益率分母为 0 / null,此时应显示 "--"
  22. final bool todayPnlRateAvailable;
  23. /// 当前市场 Tab 索引:0=自选 1=热门 2=涨幅 3=跌幅
  24. final int marketTabIndex;
  25. /// 所有币对(来自 /swap/coin/enabled-list)
  26. final List<MarketTicker> tickers;
  27. /// 涨幅榜数据(来自 /market-new/rank/rise)
  28. final List<MarketTicker> riseTickers;
  29. /// 跌幅榜数据(来自 /market-new/rank/fall)
  30. final List<MarketTicker> fallTickers;
  31. final List<ActivityBanner> banners;
  32. final List<AppHeaderItem> appHeaders;
  33. final Set<String> favorites;
  34. const HomeState({
  35. this.isLoggedIn = false,
  36. this.isLoading = false,
  37. this.errorMessage,
  38. this.totalAsset = 0,
  39. this.todayPnl = 0,
  40. this.todayPnlPct = 0,
  41. this.todayPnlRateAvailable = true,
  42. this.marketTabIndex = 1,
  43. this.tickers = const [],
  44. this.riseTickers = const [],
  45. this.fallTickers = const [],
  46. this.banners = const [],
  47. this.appHeaders = const [],
  48. this.favorites = const {},
  49. });
  50. HomeState copyWith({
  51. bool? isLoggedIn,
  52. bool? isLoading,
  53. String? errorMessage,
  54. double? totalAsset,
  55. double? todayPnl,
  56. double? todayPnlPct,
  57. bool? todayPnlRateAvailable,
  58. int? marketTabIndex,
  59. List<MarketTicker>? tickers,
  60. List<MarketTicker>? riseTickers,
  61. List<MarketTicker>? fallTickers,
  62. List<ActivityBanner>? banners,
  63. List<AppHeaderItem>? appHeaders,
  64. Set<String>? favorites,
  65. }) {
  66. return HomeState(
  67. isLoggedIn: isLoggedIn ?? this.isLoggedIn,
  68. isLoading: isLoading ?? this.isLoading,
  69. errorMessage: errorMessage,
  70. totalAsset: totalAsset ?? this.totalAsset,
  71. todayPnl: todayPnl ?? this.todayPnl,
  72. todayPnlPct: todayPnlPct ?? this.todayPnlPct,
  73. todayPnlRateAvailable: todayPnlRateAvailable ?? this.todayPnlRateAvailable,
  74. marketTabIndex: marketTabIndex ?? this.marketTabIndex,
  75. tickers: tickers ?? this.tickers,
  76. riseTickers: riseTickers ?? this.riseTickers,
  77. fallTickers: fallTickers ?? this.fallTickers,
  78. banners: banners ?? this.banners,
  79. appHeaders: appHeaders ?? this.appHeaders,
  80. favorites: favorites ?? this.favorites,
  81. );
  82. }
  83. /// 按当前 tab 返回对应数据
  84. List<MarketTicker> get displayTickers {
  85. switch (marketTabIndex) {
  86. case 0: // 自选
  87. return tickers.where((t) => favorites.contains(t.symbol)).toList();
  88. case 1: // 热门交易(isHot=1)
  89. return tickers.where((t) => t.isHot).toList();
  90. case 2: // 涨幅榜(接口数据)
  91. return riseTickers;
  92. case 3: // 跌幅榜(接口数据)
  93. return fallTickers;
  94. default:
  95. return tickers;
  96. }
  97. }
  98. }
  99. // ── Notifier ──────────────────────────────────────────────
  100. class HomeNotifier extends Notifier<HomeState> {
  101. MarketService get _service => MarketService(ref.read(dioClientProvider));
  102. StreamSubscription? _tickerSub;
  103. Timer? _throttleTimer;
  104. final _tickerBuffer = <String, ({double price, double change24h, double volume24h})>{};
  105. /// 标记当前 notifier 是否已被 dispose;
  106. /// 用于阻止 in-flight 的 _loadData / _loadAssetData 在 dispose 后写 state
  107. /// 导致 framework 抛 `_elements.contains(element)` 断言。
  108. bool _disposed = false;
  109. void _safeUpdate(HomeState Function(HomeState) update) {
  110. if (_disposed) return;
  111. state = update(state);
  112. }
  113. @override
  114. HomeState build() {
  115. // ⚠️ Notifier 实例在 invalidate 后会被复用(同实例重新 build),
  116. // class field 不会随之重置,因此必须在 build 入口手动重置 _disposed,
  117. // 否则 invalidate 之后下次 build 时 _safeUpdate / microtask 守卫
  118. // 会把整个 _loadData 静默丢掉,导致首页一直停在 shimmer。
  119. _disposed = false;
  120. _tickerBuffer.clear();
  121. ref.listen<bool>(isLoggedInProvider, (prev, loggedIn) {
  122. _safeUpdate((s) => s.copyWith(isLoggedIn: loggedIn));
  123. // 登录状态变更:刚登录时立即拉取资产;登出时清零
  124. if (loggedIn && prev != true) {
  125. Future.microtask(() => ref.read(assetProvider.notifier).silentRefresh());
  126. } else if (!loggedIn) {
  127. _safeUpdate((s) => s.copyWith(totalAsset: 0, todayPnl: 0, todayPnlPct: 0, todayPnlRateAvailable: true));
  128. }
  129. });
  130. ref.onDispose(() {
  131. _disposed = true;
  132. _tickerSub?.cancel();
  133. _throttleTimer?.cancel();
  134. });
  135. // 监听 WS 连接状态,重连成功后清空缓冲并重新加载数据
  136. ref.listen<AsyncValue<WsConnectionState>>(
  137. wsConnectionStateProvider,
  138. (prev, next) {
  139. final prevState = prev?.valueOrNull;
  140. final nextState = next.valueOrNull;
  141. if (prevState == WsConnectionState.reconnecting &&
  142. nextState == WsConnectionState.connected) {
  143. _tickerBuffer.clear();
  144. Future.microtask(() {
  145. if (_disposed) return;
  146. _loadData();
  147. });
  148. }
  149. },
  150. );
  151. Future.microtask(() {
  152. if (_disposed) return;
  153. _loadData();
  154. });
  155. final loggedIn = ref.read(isLoggedInProvider);
  156. return HomeState(isLoading: true, isLoggedIn: loggedIn);
  157. }
  158. Future<void> _loadData() async {
  159. try {
  160. // 并行请求:币对列表 + 涨幅榜 + 跌幅榜 + Banner + Header
  161. final results = await Future.wait([
  162. _service.getEnabledCoins(),
  163. _service.getRiseRank(),
  164. _service.getFallRank(),
  165. _service.getBanners(),
  166. _service.getAppHeaders(),
  167. ]);
  168. final tickers = results[0] as List<MarketTicker>;
  169. final riseRaw = results[1] as List<MarketTicker>;
  170. final fallRaw = results[2] as List<MarketTicker>;
  171. final banners = results[3] as List<ActivityBanner>;
  172. final appHeaders = results[4] as List<AppHeaderItem>;
  173. // 用币对缓存合并 icon 等信息到排行榜数据
  174. final cache = ref.read(coinCacheProvider.notifier);
  175. final rise = cache.mergeWithRank(riseRaw);
  176. final fall = cache.mergeWithRank(fallRaw);
  177. _safeUpdate((s) => s.copyWith(
  178. isLoading: false,
  179. tickers: tickers,
  180. riseTickers: rise,
  181. fallTickers: fall,
  182. banners: banners,
  183. appHeaders: appHeaders,
  184. ));
  185. // 已登录时刷新资产(首页卡片与资产页共用 assetProvider)
  186. if (!_disposed && state.isLoggedIn) {
  187. Future.microtask(() => ref.read(assetProvider.notifier).silentRefresh());
  188. }
  189. // 收集所有需要订阅的 symbol(去重)
  190. if (_disposed) return;
  191. final allSymbols = <String>{
  192. ...tickers.map((t) => t.symbol),
  193. ...rise.map((t) => t.symbol),
  194. ...fall.map((t) => t.symbol),
  195. };
  196. _subscribeWs(allSymbols.toList());
  197. } catch (_) {
  198. _safeUpdate((s) => s.copyWith(isLoading: false));
  199. }
  200. }
  201. /// 订阅 WS ticker 流
  202. void _subscribeWs(List<String> symbols) {
  203. final ws = ref.read(wsClientProvider);
  204. // 差量订阅:自动退订已下架币对,订阅新增币对
  205. ws.resubscribeTickerBatch(symbols);
  206. _tickerSub?.cancel();
  207. _tickerSub = ws.tickerStream.listen(_onTickerUpdate);
  208. _throttleTimer?.cancel();
  209. _throttleTimer = Timer.periodic(
  210. const Duration(milliseconds: 500),
  211. (_) => _flushTickerBuffer(),
  212. );
  213. }
  214. void _onTickerUpdate(Map<String, dynamic> data) {
  215. final symbol = data['symbol'] as String?;
  216. final price = (data['price'] as num?)?.toDouble();
  217. if (symbol == null || price == null) return;
  218. _tickerBuffer[symbol] = (
  219. price: price,
  220. change24h: (data['change24h'] as num?)?.toDouble() ?? 0,
  221. volume24h: (data['volume24h'] as num?)?.toDouble() ?? 0,
  222. );
  223. }
  224. /// 批量刷新:将 WS 价格更新合并到 tickers / riseTickers / fallTickers
  225. void _flushTickerBuffer() {
  226. if (_disposed) return;
  227. if (_tickerBuffer.isEmpty) return;
  228. state = state.copyWith(
  229. tickers: _mergeBuffer(state.tickers),
  230. riseTickers: _mergeBuffer(state.riseTickers),
  231. fallTickers: _mergeBuffer(state.fallTickers),
  232. );
  233. _tickerBuffer.clear();
  234. }
  235. List<MarketTicker> _mergeBuffer(List<MarketTicker> list) {
  236. return list.map((t) {
  237. final u = _tickerBuffer[t.symbol];
  238. if (u == null) return t;
  239. return t.copyWith(
  240. lastPrice: u.price,
  241. change24h: u.change24h,
  242. volume24h: u.volume24h,
  243. );
  244. }).toList();
  245. }
  246. /// 下拉刷新:重新拉取行情 + 资产 + 顶级交易员
  247. Future<void> refresh() async {
  248. final futures = <Future<void>>[
  249. _loadData(),
  250. ref.read(topTraderProvider.notifier).refresh(),
  251. ];
  252. if (state.isLoggedIn) {
  253. futures.add(ref.read(assetProvider.notifier).silentRefresh());
  254. }
  255. await Future.wait(futures);
  256. }
  257. /// 仅刷新资产数据(切回首页 Tab 时触发,与资产页同源)
  258. Future<void> refreshAsset() async {
  259. if (!state.isLoggedIn) return;
  260. await ref.read(assetProvider.notifier).silentRefresh();
  261. }
  262. /// 切换市场 Tab
  263. void setMarketTab(int index) {
  264. state = state.copyWith(marketTabIndex: index);
  265. }
  266. /// 切换自选
  267. void toggleFavorite(String symbol) {
  268. final favs = Set<String>.from(state.favorites);
  269. if (favs.contains(symbol)) {
  270. favs.remove(symbol);
  271. } else {
  272. favs.add(symbol);
  273. }
  274. state = state.copyWith(favorites: favs);
  275. }
  276. }
  277. final homeProvider = NotifierProvider<HomeNotifier, HomeState>(
  278. HomeNotifier.new,
  279. );