| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348 |
- 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<MarketTicker> tickers;
- /// 涨幅榜数据(来自 /market-new/rank/rise)
- final List<MarketTicker> riseTickers;
- /// 跌幅榜数据(来自 /market-new/rank/fall)
- final List<MarketTicker> fallTickers;
- final List<ActivityBanner> banners;
- final List<AppHeaderItem> appHeaders;
- final Set<String> 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<MarketTicker>? tickers,
- List<MarketTicker>? riseTickers,
- List<MarketTicker>? fallTickers,
- List<ActivityBanner>? banners,
- List<AppHeaderItem>? appHeaders,
- Set<String>? 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<MarketTicker> 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<HomeState> {
- MarketService get _service => MarketService(ref.read(dioClientProvider));
- StreamSubscription? _tickerSub;
- Timer? _throttleTimer;
- final _tickerBuffer = <String, ({double price, double change24h, double volume24h})>{};
- /// 标记当前 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<bool>(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<AsyncValue<WsConnectionState>>(
- 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<void> _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<MarketTicker>;
- final riseRaw = results[1] as List<MarketTicker>;
- final fallRaw = results[2] as List<MarketTicker>;
- final banners = results[3] as List<ActivityBanner>;
- final appHeaders = results[4] as List<AppHeaderItem>;
- // 用币对缓存合并 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 = <String>{
- ...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<String> 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<String, dynamic> 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<MarketTicker> _mergeBuffer(List<MarketTicker> 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<void> _loadAssetData() async {
- try {
- final dio = ref.read(dioClientProvider);
- final results = await Future.wait([
- AssetService(dio).getTodayPnl(),
- SpotService(dio).getAssets().catchError((_) => <String, dynamic>{}),
- ]);
- final pnl = results[0] as dynamic; // TodayPnl
- final spotData = results[1] as Map<String, dynamic>;
- 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<void> refresh() async {
- await Future.wait([
- _loadData(),
- ref.read(topTraderProvider.notifier).refresh(),
- ]);
- }
- /// 仅刷新资产数据(点击资产卡片触发)
- Future<void> 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<String>.from(state.favorites);
- if (favs.contains(symbol)) {
- favs.remove(symbol);
- } else {
- favs.add(symbol);
- }
- state = state.copyWith(favorites: favs);
- }
- }
- final homeProvider = NotifierProvider<HomeNotifier, HomeState>(
- HomeNotifier.new,
- );
|