| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472 |
- 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<FuturesPosition> positions;
- /// 现货账户 USDT 估值(HTTP)
- final double spotTradingTotal;
- final double spotTodayPnl;
- final double spotTodayPnlRate;
- final List<SpotWalletAsset> spotWallets;
- final List<SpotWalletAsset> 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<FuturesPosition>? positions,
- double? spotTradingTotal,
- double? spotTodayPnl,
- double? spotTodayPnlRate,
- List<SpotWalletAsset>? spotWallets,
- List<SpotWalletAsset>? 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<AssetState> {
- Timer? _pollTimer;
- StreamSubscription<Map<String, dynamic>>? _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<SpotWsClient>(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<AsyncValue<SpotWsState>>(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<bool>(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<int>(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<int>(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<void> onSpotTradingTabVisible() async {
- if (!ref.read(isLoggedInProvider)) return;
- if (!_spotTradingTabHttpBootstrapped) {
- _spotTradingTabHttpBootstrapped = true;
- await _loadSpotSliceFromHttp();
- }
- _ensureSpotAssetWs();
- }
- void onSpotTradingTabHidden() => _teardownSpotAssetWs();
- Future<void> _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<void> _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<String, dynamic> 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<SpotWalletAsset> _mergeSpotWallets(
- List<SpotWalletAsset> current,
- List<dynamic> incoming,
- ) {
- final byCoin = <String, SpotWalletAsset>{
- for (final w in current) w.coin: w,
- };
- for (final raw in incoming) {
- if (raw is! Map) continue;
- final a = SpotWalletAsset.fromJson(Map<String, dynamic>.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<void> 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((_) => <String, dynamic>{}),
- SpotService(dio)
- .getAssets(hideZero: state.hideZeroBalanceInSpotTab)
- .catchError((_) => <String, dynamic>{}),
- AssetService(dio)
- .getFundAssets(hideZero: state.hideZeroBalanceInFundTab)
- .catchError((_) => <String, dynamic>{}),
- StakingService(dio)
- .getStakingWalletBalance('IBIT')
- .catchError((_) => StakingWalletBalance.empty('IBIT')),
- ]);
- final todayPnl = results[0] as TodayPnl;
- final posData = results[1] as Map<String, dynamic>;
- final rawPositions =
- (posData['currentPositionWithCutList'] as List<dynamic>? ?? [])
- .cast<Map<String, dynamic>>();
- final positions = rawPositions
- .map((e) {
- try {
- return FuturesPosition.fromJson(e);
- } catch (_) {
- return null;
- }
- })
- .whereType<FuturesPosition>()
- .toList();
- final spotData = results[2] as Map<String, dynamic>;
- 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<String, dynamic>>()
- .map(SpotWalletAsset.fromJson)
- .toList()
- : <SpotWalletAsset>[];
- final fundData = results[3] as Map<String, dynamic>;
- 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<void> refresh() => loadAssets();
- Future<void> 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<SpotWalletAsset> _parseSpotWallets(Map<String, dynamic> payload) {
- final rawAssetList = payload['assetList'];
- if (rawAssetList is! List) {
- return <SpotWalletAsset>[];
- }
- return rawAssetList
- .whereType<Map<String, dynamic>>()
- .map(SpotWalletAsset.fromJson)
- .toList();
- }
- }
- final assetProvider = AutoDisposeNotifierProvider<AssetNotifier, AssetState>(
- AssetNotifier.new,
- );
- /// 资产页子 tab:0 总览 1 现货 2 合约…
- final currentAssetSubTabProvider = StateProvider<int>((ref) => 0);
|