import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/network/dio_client.dart'; import '../data/models/home/app_header_item.dart'; import '../data/models/home/market_ticker.dart'; import '../data/models/home/activity_banner.dart'; import '../data/services/asset_service.dart'; import '../data/services/market_service.dart'; import '../data/services/spot_service.dart'; import 'auth_provider.dart'; import 'coin_cache_provider.dart'; import 'top_trader_provider.dart'; import 'ws_provider.dart'; // ── UI State ────────────────────────────────────────────── class HomeState { final bool isLoggedIn; final bool isLoading; final String? errorMessage; final double totalAsset; final double todayPnl; final double todayPnlPct; /// false 表示后端收益率分母为 0 / null,此时应显示 "--" final bool todayPnlRateAvailable; /// 当前市场 Tab 索引:0=自选 1=热门 2=涨幅 3=跌幅 final int marketTabIndex; /// 所有币对(来自 /swap/coin/enabled-list) final List tickers; /// 涨幅榜数据(来自 /market-new/rank/rise) final List riseTickers; /// 跌幅榜数据(来自 /market-new/rank/fall) final List fallTickers; final List banners; final List appHeaders; final Set favorites; const HomeState({ this.isLoggedIn = false, this.isLoading = false, this.errorMessage, this.totalAsset = 0, this.todayPnl = 0, this.todayPnlPct = 0, this.todayPnlRateAvailable = true, this.marketTabIndex = 1, this.tickers = const [], this.riseTickers = const [], this.fallTickers = const [], this.banners = const [], this.appHeaders = const [], this.favorites = const {}, }); HomeState copyWith({ bool? isLoggedIn, bool? isLoading, String? errorMessage, double? totalAsset, double? todayPnl, double? todayPnlPct, bool? todayPnlRateAvailable, int? marketTabIndex, List? tickers, List? riseTickers, List? fallTickers, List? banners, List? appHeaders, Set? favorites, }) { return HomeState( isLoggedIn: isLoggedIn ?? this.isLoggedIn, isLoading: isLoading ?? this.isLoading, errorMessage: errorMessage, totalAsset: totalAsset ?? this.totalAsset, todayPnl: todayPnl ?? this.todayPnl, todayPnlPct: todayPnlPct ?? this.todayPnlPct, todayPnlRateAvailable: todayPnlRateAvailable ?? this.todayPnlRateAvailable, marketTabIndex: marketTabIndex ?? this.marketTabIndex, tickers: tickers ?? this.tickers, riseTickers: riseTickers ?? this.riseTickers, fallTickers: fallTickers ?? this.fallTickers, banners: banners ?? this.banners, appHeaders: appHeaders ?? this.appHeaders, favorites: favorites ?? this.favorites, ); } /// 按当前 tab 返回对应数据 List get displayTickers { switch (marketTabIndex) { case 0: // 自选 return tickers.where((t) => favorites.contains(t.symbol)).toList(); case 1: // 热门交易(isHot=1) return tickers.where((t) => t.isHot).toList(); case 2: // 涨幅榜(接口数据) return riseTickers; case 3: // 跌幅榜(接口数据) return fallTickers; default: return tickers; } } } // ── Notifier ────────────────────────────────────────────── class HomeNotifier extends Notifier { MarketService get _service => MarketService(ref.read(dioClientProvider)); StreamSubscription? _tickerSub; Timer? _throttleTimer; final _tickerBuffer = {}; /// 标记当前 notifier 是否已被 dispose; /// 用于阻止 in-flight 的 _loadData / _loadAssetData 在 dispose 后写 state /// 导致 framework 抛 `_elements.contains(element)` 断言。 bool _disposed = false; void _safeUpdate(HomeState Function(HomeState) update) { if (_disposed) return; state = update(state); } @override HomeState build() { // ⚠️ Notifier 实例在 invalidate 后会被复用(同实例重新 build), // class field 不会随之重置,因此必须在 build 入口手动重置 _disposed, // 否则 invalidate 之后下次 build 时 _safeUpdate / microtask 守卫 // 会把整个 _loadData 静默丢掉,导致首页一直停在 shimmer。 _disposed = false; _tickerBuffer.clear(); ref.listen(isLoggedInProvider, (prev, loggedIn) { _safeUpdate((s) => s.copyWith(isLoggedIn: loggedIn)); // 登录状态变更:刚登录时立即拉取资产;登出时清零 if (loggedIn && prev != true) { _loadAssetData(); } else if (!loggedIn) { _safeUpdate((s) => s.copyWith(totalAsset: 0, todayPnl: 0, todayPnlPct: 0, todayPnlRateAvailable: true)); } }); ref.onDispose(() { _disposed = true; _tickerSub?.cancel(); _throttleTimer?.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(() { if (_disposed) return; _loadData(); }); } }, ); Future.microtask(() { if (_disposed) return; _loadData(); }); final loggedIn = ref.read(isLoggedInProvider); return HomeState(isLoading: true, isLoggedIn: loggedIn); } Future _loadData() async { try { // 并行请求:币对列表 + 涨幅榜 + 跌幅榜 + Banner + Header final results = await Future.wait([ _service.getEnabledCoins(), _service.getRiseRank(), _service.getFallRank(), _service.getBanners(), _service.getAppHeaders(), ]); final tickers = results[0] as List; final riseRaw = results[1] as List; final fallRaw = results[2] as List; final banners = results[3] as List; final appHeaders = results[4] as List; // 用币对缓存合并 icon 等信息到排行榜数据 final cache = ref.read(coinCacheProvider.notifier); final rise = cache.mergeWithRank(riseRaw); final fall = cache.mergeWithRank(fallRaw); _safeUpdate((s) => s.copyWith( isLoading: false, tickers: tickers, riseTickers: rise, fallTickers: fall, banners: banners, appHeaders: appHeaders, )); // 已登录时加载总资产 + 今日盈亏 if (!_disposed && state.isLoggedIn) { _loadAssetData(); } // 收集所有需要订阅的 symbol(去重) if (_disposed) return; final allSymbols = { ...tickers.map((t) => t.symbol), ...rise.map((t) => t.symbol), ...fall.map((t) => t.symbol), }; _subscribeWs(allSymbols.toList()); } catch (_) { _safeUpdate((s) => s.copyWith(isLoading: false)); } } /// 订阅 WS ticker 流 void _subscribeWs(List symbols) { final ws = ref.read(wsClientProvider); // 差量订阅:自动退订已下架币对,订阅新增币对 ws.resubscribeTickerBatch(symbols); _tickerSub?.cancel(); _tickerSub = ws.tickerStream.listen(_onTickerUpdate); _throttleTimer?.cancel(); _throttleTimer = Timer.periodic( const Duration(milliseconds: 500), (_) => _flushTickerBuffer(), ); } void _onTickerUpdate(Map data) { final symbol = data['symbol'] as String?; final price = (data['price'] as num?)?.toDouble(); if (symbol == null || price == null) return; _tickerBuffer[symbol] = ( price: price, change24h: (data['change24h'] as num?)?.toDouble() ?? 0, volume24h: (data['volume24h'] as num?)?.toDouble() ?? 0, ); } /// 批量刷新:将 WS 价格更新合并到 tickers / riseTickers / fallTickers void _flushTickerBuffer() { if (_disposed) return; if (_tickerBuffer.isEmpty) return; state = state.copyWith( tickers: _mergeBuffer(state.tickers), riseTickers: _mergeBuffer(state.riseTickers), fallTickers: _mergeBuffer(state.fallTickers), ); _tickerBuffer.clear(); } List _mergeBuffer(List list) { return list.map((t) { final u = _tickerBuffer[t.symbol]; if (u == null) return t; return t.copyWith( lastPrice: u.price, change24h: u.change24h, volume24h: u.volume24h, ); }).toList(); } /// 加载总资产 + 今日盈亏(登录状态下调用) /// 总资产 = 旧账户体系(合约/跟单/资金)+ 新现货账户 Future _loadAssetData() async { try { final dio = ref.read(dioClientProvider); final results = await Future.wait([ AssetService(dio).getTodayPnl(), SpotService(dio).getAssets().catchError((_) => {}), ]); final pnl = results[0] as dynamic; // TodayPnl final spotData = results[1] as Map; final legacyTotal = (pnl.accountInfoList as List).isNotEmpty ? (pnl.accountInfoList as List).fold(0.0, (sum, a) => sum + (a.currentCapital as dynamic).toDouble()) : (pnl.cashBalance as dynamic).toDouble(); final spotTotal = _toDoubleHome(spotData['totalAmount']); _safeUpdate((s) => s.copyWith( totalAsset: legacyTotal + spotTotal, todayPnl: (pnl.revenue as dynamic).toDouble(), todayPnlPct: ((pnl.revenueRate as dynamic)?.toDouble() ?? 0) * 100, todayPnlRateAvailable: pnl.revenueRate != null, )); } catch (_) {} } static double _toDoubleHome(dynamic v) { if (v == null) return 0.0; if (v is num) return v.toDouble(); return double.tryParse(v.toString()) ?? 0.0; } /// 下拉刷新:重新拉取行情 + 资产 + 顶级交易员 Future refresh() async { await Future.wait([ _loadData(), ref.read(topTraderProvider.notifier).refresh(), ]); } /// 仅刷新资产数据(点击资产卡片触发) Future refreshAsset() async { if (!state.isLoggedIn) return; await _loadAssetData(); } /// 切换市场 Tab void setMarketTab(int index) { state = state.copyWith(marketTabIndex: index); } /// 切换自选 void toggleFavorite(String symbol) { final favs = Set.from(state.favorites); if (favs.contains(symbol)) { favs.remove(symbol); } else { favs.add(symbol); } state = state.copyWith(favorites: favs); } } final homeProvider = NotifierProvider( HomeNotifier.new, );