import 'dart:async'; import 'package:decimal/decimal.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/network/dio_client.dart'; import '../core/network/spot_ws_client.dart'; import '../data/models/asset/today_pnl.dart'; import '../data/services/asset_service.dart'; import '../data/services/futures_service.dart'; import '../data/services/spot_service.dart'; import '../data/models/finance/staking_wallet_balance.dart'; import '../data/services/staking_service.dart'; import '../core/utils/spot_transfer_asset.dart'; import 'auth_provider.dart'; import 'futures_provider.dart' show FuturesPosition, activeBottomTabProvider; import 'spot_provider.dart' show SpotWalletAsset; import 'spot_ws_provider.dart'; class AssetState { /// swap/wallet-new/get 返回的账户数据 final TodayPnl? todayPnl; /// 合约持仓列表(GET swap/wallet-new/get-with-positions 的 currentPositionWithCutList) final List positions; /// 现货账户 USDT 估值(HTTP) final double spotTradingTotal; final double spotTodayPnl; final double spotTodayPnlRate; final List spotWallets; final List fundWallets; /// 锁仓账户 iBit 冻结量(总览展示,对齐 Web STAKING_LOCKED) final String stakingOverviewLocked; final bool hideZeroBalanceInFundTab; final bool hideZeroBalanceInSpotTab; final bool obscureBalance; final bool isLoading; final String? errorMessage; const AssetState({ this.todayPnl, this.positions = const [], this.spotTradingTotal = 0, this.spotTodayPnl = 0, this.spotTodayPnlRate = 0, this.spotWallets = const [], this.fundWallets = const [], this.stakingOverviewLocked = '0', this.hideZeroBalanceInFundTab = false, this.hideZeroBalanceInSpotTab = false, this.obscureBalance = false, this.isLoading = false, this.errorMessage, }); Decimal get totalAssetDecimal { final list = todayPnl?.accountInfoList; final base = list == null || list.isEmpty ? Decimal.zero : list.fold(Decimal.zero, (sum, a) => sum + a.currentCapital); final spotDecimal = Decimal.tryParse(spotTradingTotal.toString()) ?? Decimal.zero; return base + spotDecimal; } double get totalUsdtValue => totalAssetDecimal.toDouble(); /// 按账户名称取 currentCapital /// SWAP → 永续合约资产, FOLLOW → 跟单账户资产, SPOT → 资金账户资产 /// 按固定索引取账户资产:SWAP=0, FOLLOW=1, SPOT=2 /// 服务端 accountInfoList 顺序固定,name 字段随 lang 变化,不可用于匹配 int _walletIndex(String walletType) => switch (walletType) { 'SWAP' => 0, 'FOLLOW' => 1, 'SPOT' => 2, _ => -1, }; Decimal walletBalance(String walletType) { final list = todayPnl?.accountInfoList; if (list == null) return Decimal.zero; final i = _walletIndex(walletType); if (i < 0 || i >= list.length) return Decimal.zero; return list[i].currentCapital; } /// 获取账户的钱包余额 Decimal accountBalance(String walletType) { final list = todayPnl?.accountInfoList; if (list == null) return Decimal.zero; final i = _walletIndex(walletType); if (i < 0 || i >= list.length) return Decimal.zero; return list[i].balance; } /// 获取账户的未实现盈亏(从持仓累加,不含体验金仓位) Decimal unrealizedPnl(String walletType) { // 仅合约账户有持仓数据 if (walletType != 'SWAP') return Decimal.zero; if (positions.isEmpty) return Decimal.zero; return positions.where((p) => p.marginMode != '体验金').fold(Decimal.zero, (sum, p) { final pnl = Decimal.tryParse(p.unrealizedPnl.toString()) ?? Decimal.zero; return sum + pnl; }); } /// 合约账户净值(不含体验金) /// 后端 getCurrentRevenue() 已过滤体验金浮盈亏,currentCapital 本身已正确,直接返回即可 Decimal walletBalanceExcludeEG(String walletType) => walletBalance(walletType); AssetState copyWith({ TodayPnl? todayPnl, List? positions, double? spotTradingTotal, double? spotTodayPnl, double? spotTodayPnlRate, List? spotWallets, List? fundWallets, String? stakingOverviewLocked, bool? hideZeroBalanceInFundTab, bool? hideZeroBalanceInSpotTab, bool? obscureBalance, bool? isLoading, String? errorMessage, }) => AssetState( todayPnl: todayPnl ?? this.todayPnl, positions: positions ?? this.positions, spotTradingTotal: spotTradingTotal ?? this.spotTradingTotal, spotTodayPnl: spotTodayPnl ?? this.spotTodayPnl, spotTodayPnlRate: spotTodayPnlRate ?? this.spotTodayPnlRate, spotWallets: spotWallets ?? this.spotWallets, fundWallets: fundWallets ?? this.fundWallets, stakingOverviewLocked: stakingOverviewLocked ?? this.stakingOverviewLocked, hideZeroBalanceInFundTab: hideZeroBalanceInFundTab ?? this.hideZeroBalanceInFundTab, hideZeroBalanceInSpotTab: hideZeroBalanceInSpotTab ?? this.hideZeroBalanceInSpotTab, obscureBalance: obscureBalance ?? this.obscureBalance, isLoading: isLoading ?? this.isLoading, errorMessage: errorMessage, ); } class AssetNotifier extends AutoDisposeNotifier { Timer? _pollTimer; StreamSubscription>? _spotAssetWsSub; bool _spotTradingTabHttpBootstrapped = false; bool _spotAssetChannelRetained = false; @override AssetState build() { // keepAlive 保证 provider 不因无 watcher 而销毁,同时 AutoDispose // 机制会在 ConsumerStatefulElement.deactivate() 时立即移除监听, // 避免 loadAssets async 回调命中已 defunct 的 element。 ref.keepAlive(); ref.onDispose(() { _pollTimer?.cancel(); _teardownSpotAssetWs(); }); ref.listen(spotWsClientProvider, (prev, next) { if (prev != null && !identical(prev, next)) { _spotAssetWsSub?.cancel(); _spotAssetWsSub = null; _spotAssetChannelRetained = false; final onAssetPage = ref.read(activeBottomTabProvider) == 5; final onSpotSubTab = ref.read(currentAssetSubTabProvider) == 2; if (onAssetPage && onSpotSubTab && ref.read(isLoggedInProvider)) { Future.microtask(() => _ensureSpotAssetWs()); } } }); ref.listen>(spotWsConnectionStateProvider, (prev, next) { final s = next.valueOrNull; if (s != SpotWsState.connected) return; if (!ref.read(isLoggedInProvider)) return; if (prev?.valueOrNull == SpotWsState.reconnecting) { Future.microtask(() => _loadSpotSliceFromHttp()); } }); ref.listen(isLoggedInProvider, (prev, loggedIn) { if (loggedIn) { _spotTradingTabHttpBootstrapped = false; Future.microtask(() { if (ref.read(activeBottomTabProvider) == 5 && ref.read(currentAssetSubTabProvider) == 2) { onSpotTradingTabVisible(); } }); } else { _spotTradingTabHttpBootstrapped = false; _teardownSpotAssetWs(); } }); ref.listen(currentAssetSubTabProvider, (prev, subTab) { if (ref.read(activeBottomTabProvider) != 5) return; if (subTab == 3) { startPositionPolling(); } else { stopPositionPolling(); } if (subTab == 2 && ref.read(isLoggedInProvider)) { Future.microtask(() => onSpotTradingTabVisible()); } else if (prev == 2) { onSpotTradingTabHidden(); } }); ref.listen(activeBottomTabProvider, (prev, tabIndex) { if (tabIndex != 5) { stopPositionPolling(); onSpotTradingTabHidden(); } else { final subTab = ref.read(currentAssetSubTabProvider); if (subTab == 3) { startPositionPolling(); } if (subTab == 2 && ref.read(isLoggedInProvider)) { Future.microtask(() => onSpotTradingTabVisible()); } } }); Future.microtask(loadAssets); return const AssetState(isLoading: true); } Future onSpotTradingTabVisible() async { if (!ref.read(isLoggedInProvider)) return; if (!_spotTradingTabHttpBootstrapped) { _spotTradingTabHttpBootstrapped = true; await _loadSpotSliceFromHttp(); } _ensureSpotAssetWs(); } void onSpotTradingTabHidden() => _teardownSpotAssetWs(); Future _loadSpotSliceFromHttp() async { if (!ref.read(isLoggedInProvider)) return; try { final dio = ref.read(dioClientProvider); final spotData = await SpotService(dio) .getAssets(hideZero: state.hideZeroBalanceInSpotTab); final spotTotal = _toDouble(spotData['totalAmount']); final spotPnl = _toDouble(spotData['todayPnl']); final spotPnlRate = _toDouble(spotData['todayPnlRate']); final spotWallets = _parseSpotWallets(spotData); state = state.copyWith( spotTradingTotal: spotTotal, spotTodayPnl: spotPnl, spotTodayPnlRate: spotPnlRate, spotWallets: spotWallets, ); } catch (_) {} } Future _loadFundSliceFromHttp() async { if (!ref.read(isLoggedInProvider)) return; try { final dio = ref.read(dioClientProvider); final fundData = await AssetService(dio) .getFundAssets(hideZero: state.hideZeroBalanceInFundTab); state = state.copyWith(fundWallets: _parseSpotWallets(fundData)); } catch (_) {} } void _ensureSpotAssetWs() { if (!ref.read(isLoggedInProvider)) return; _spotAssetWsSub?.cancel(); _spotAssetWsSub = null; final ws = ref.read(spotWsClientProvider); if (!_spotAssetChannelRetained) { ws.retainSpotAssetChannel(); _spotAssetChannelRetained = true; } _spotAssetWsSub = ws.assetStream.listen(_onSpotAssetPush); } void _teardownSpotAssetWs() { _spotAssetWsSub?.cancel(); _spotAssetWsSub = null; if (!_spotAssetChannelRetained) return; try { ref.read(spotWsClientProvider).releaseSpotAssetChannel(); } catch (_) {} _spotAssetChannelRetained = false; } void _onSpotAssetPush(Map msg) { final list = msg['accountList']; if (list is! List) return; if (list.isEmpty) { Future.microtask(() => _loadSpotSliceFromHttp()); return; } final merged = _mergeSpotWallets(state.spotWallets, list); state = state.copyWith(spotWallets: merged); } List _mergeSpotWallets( List current, List incoming, ) { final byCoin = { for (final w in current) w.coin: w, }; for (final raw in incoming) { if (raw is! Map) continue; final a = SpotWalletAsset.fromJson(Map.from(raw)); if (a.coin.isEmpty) continue; byCoin[a.coin] = a; } final out = byCoin.values.toList() ..sort((a, b) => a.coin.compareTo(b.coin)); return out; } void startPositionPolling() { _pollTimer?.cancel(); _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) { if (ref.read(activeBottomTabProvider) != 5) { stopPositionPolling(); return; } silentRefresh(); }); } void stopPositionPolling() { _pollTimer?.cancel(); _pollTimer = null; } Future loadAssets({bool silent = false}) async { if (!silent) state = state.copyWith(isLoading: true, errorMessage: null); final dio = ref.read(dioClientProvider); try { final results = await Future.wait([ AssetService(dio).getTodayPnl().catchError((_) => TodayPnl()), FuturesService(dio) .getWithPositions() .catchError((_) => {}), SpotService(dio) .getAssets(hideZero: state.hideZeroBalanceInSpotTab) .catchError((_) => {}), AssetService(dio) .getFundAssets(hideZero: state.hideZeroBalanceInFundTab) .catchError((_) => {}), StakingService(dio) .getStakingWalletBalance('IBIT') .catchError((_) => StakingWalletBalance.empty('IBIT')), ]); final todayPnl = results[0] as TodayPnl; final posData = results[1] as Map; final rawPositions = (posData['currentPositionWithCutList'] as List? ?? []) .cast>(); final positions = rawPositions .map((e) { try { return FuturesPosition.fromJson(e); } catch (_) { return null; } }) .whereType() .toList(); final spotData = results[2] as Map; final spotTotal = _toDouble(spotData['totalAmount']); final spotPnl = _toDouble(spotData['todayPnl']); final spotPnlRate = _toDouble(spotData['todayPnlRate']); final rawAssetList = spotData['assetList']; final spotWallets = rawAssetList is List ? rawAssetList .whereType>() .map(SpotWalletAsset.fromJson) .toList() : []; final fundData = results[3] as Map; final fundWallets = _parseSpotWallets(fundData); final stakingWallet = results[4] as StakingWalletBalance; var stakingLocked = stakingOverviewLockedFromAccounts(todayPnl.accountInfoList); final apiLocked = stakingWallet.lockedBalance; if ((double.tryParse(stakingLocked) ?? 0) <= 0 && (double.tryParse(apiLocked) ?? 0) > 0) { stakingLocked = apiLocked; } state = state.copyWith( todayPnl: todayPnl, positions: positions, spotTradingTotal: spotTotal, spotTodayPnl: spotPnl, spotTodayPnlRate: spotPnlRate, spotWallets: spotWallets, fundWallets: fundWallets, stakingOverviewLocked: stakingLocked, isLoading: false, ); } catch (e) { if (!silent) { state = state.copyWith(isLoading: false, errorMessage: e.toString()); } } } static double _toDouble(dynamic v) { if (v == null) return 0.0; if (v is num) return v.toDouble(); return double.tryParse(v.toString()) ?? 0.0; } Future refresh() => loadAssets(); Future silentRefresh() => loadAssets(silent: true); void toggleObscure() => state = state.copyWith(obscureBalance: !state.obscureBalance); void toggleHideZeroBalanceInFundTab() { state = state.copyWith( hideZeroBalanceInFundTab: !state.hideZeroBalanceInFundTab, ); Future.microtask(_loadFundSliceFromHttp); } void toggleHideZeroBalanceInSpotTab() { state = state.copyWith( hideZeroBalanceInSpotTab: !state.hideZeroBalanceInSpotTab, ); Future.microtask(_loadSpotSliceFromHttp); } static List _parseSpotWallets(Map payload) { final rawAssetList = payload['assetList']; if (rawAssetList is! List) { return []; } return rawAssetList .whereType>() .map(SpotWalletAsset.fromJson) .toList(); } } final assetProvider = AutoDisposeNotifierProvider( AssetNotifier.new, ); /// 资产页子 tab:0 总览 1 现货 2 合约… final currentAssetSubTabProvider = StateProvider((ref) => 0);