home_provider.dart 11 KB

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