asset_provider.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. import 'dart:async';
  2. import 'package:decimal/decimal.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import '../core/network/dio_client.dart';
  5. import '../core/network/spot_ws_client.dart';
  6. import '../data/models/asset/today_pnl.dart';
  7. import '../data/services/asset_service.dart';
  8. import '../data/services/futures_service.dart';
  9. import '../data/services/spot_service.dart';
  10. import '../data/models/finance/staking_wallet_balance.dart';
  11. import '../data/services/staking_service.dart';
  12. import '../core/utils/spot_transfer_asset.dart';
  13. import 'auth_provider.dart';
  14. import 'futures_provider.dart' show FuturesPosition, activeBottomTabProvider;
  15. import 'spot_provider.dart' show SpotWalletAsset;
  16. import 'spot_ws_provider.dart';
  17. class AssetState {
  18. /// swap/wallet-new/get 返回的账户数据
  19. final TodayPnl? todayPnl;
  20. /// 合约持仓列表(GET swap/wallet-new/get-with-positions 的 currentPositionWithCutList)
  21. final List<FuturesPosition> positions;
  22. /// 现货账户 USDT 估值(HTTP)
  23. final double spotTradingTotal;
  24. final double spotTodayPnl;
  25. final double spotTodayPnlRate;
  26. final List<SpotWalletAsset> spotWallets;
  27. final List<SpotWalletAsset> fundWallets;
  28. /// 锁仓账户 iBit 冻结量(总览展示,对齐 Web STAKING_LOCKED)
  29. final String stakingOverviewLocked;
  30. final bool hideZeroBalanceInFundTab;
  31. final bool hideZeroBalanceInSpotTab;
  32. final bool obscureBalance;
  33. final bool isLoading;
  34. final String? errorMessage;
  35. const AssetState({
  36. this.todayPnl,
  37. this.positions = const [],
  38. this.spotTradingTotal = 0,
  39. this.spotTodayPnl = 0,
  40. this.spotTodayPnlRate = 0,
  41. this.spotWallets = const [],
  42. this.fundWallets = const [],
  43. this.stakingOverviewLocked = '0',
  44. this.hideZeroBalanceInFundTab = false,
  45. this.hideZeroBalanceInSpotTab = false,
  46. this.obscureBalance = false,
  47. this.isLoading = false,
  48. this.errorMessage,
  49. });
  50. Decimal get totalAssetDecimal {
  51. final list = todayPnl?.accountInfoList;
  52. final base = list == null || list.isEmpty
  53. ? Decimal.zero
  54. : list.fold(Decimal.zero, (sum, a) => sum + a.currentCapital);
  55. final spotDecimal =
  56. Decimal.tryParse(spotTradingTotal.toString()) ?? Decimal.zero;
  57. return base + spotDecimal;
  58. }
  59. double get totalUsdtValue => totalAssetDecimal.toDouble();
  60. /// 按账户名称取 currentCapital
  61. /// SWAP → 永续合约资产, FOLLOW → 跟单账户资产, SPOT → 资金账户资产
  62. /// 按固定索引取账户资产:SWAP=0, FOLLOW=1, SPOT=2
  63. /// 服务端 accountInfoList 顺序固定,name 字段随 lang 变化,不可用于匹配
  64. int _walletIndex(String walletType) => switch (walletType) {
  65. 'SWAP' => 0,
  66. 'FOLLOW' => 1,
  67. 'SPOT' => 2,
  68. _ => -1,
  69. };
  70. Decimal walletBalance(String walletType) {
  71. final list = todayPnl?.accountInfoList;
  72. if (list == null) return Decimal.zero;
  73. final i = _walletIndex(walletType);
  74. if (i < 0 || i >= list.length) return Decimal.zero;
  75. return list[i].currentCapital;
  76. }
  77. /// 获取账户的钱包余额
  78. Decimal accountBalance(String walletType) {
  79. final list = todayPnl?.accountInfoList;
  80. if (list == null) return Decimal.zero;
  81. final i = _walletIndex(walletType);
  82. if (i < 0 || i >= list.length) return Decimal.zero;
  83. return list[i].balance;
  84. }
  85. /// 获取账户的未实现盈亏(从持仓累加,不含体验金仓位)
  86. Decimal unrealizedPnl(String walletType) {
  87. // 仅合约账户有持仓数据
  88. if (walletType != 'SWAP') return Decimal.zero;
  89. if (positions.isEmpty) return Decimal.zero;
  90. return positions.where((p) => p.marginMode != '体验金').fold(Decimal.zero,
  91. (sum, p) {
  92. final pnl = Decimal.tryParse(p.unrealizedPnl.toString()) ?? Decimal.zero;
  93. return sum + pnl;
  94. });
  95. }
  96. /// 合约账户净值(不含体验金)
  97. /// 后端 getCurrentRevenue() 已过滤体验金浮盈亏,currentCapital 本身已正确,直接返回即可
  98. Decimal walletBalanceExcludeEG(String walletType) =>
  99. walletBalance(walletType);
  100. AssetState copyWith({
  101. TodayPnl? todayPnl,
  102. List<FuturesPosition>? positions,
  103. double? spotTradingTotal,
  104. double? spotTodayPnl,
  105. double? spotTodayPnlRate,
  106. List<SpotWalletAsset>? spotWallets,
  107. List<SpotWalletAsset>? fundWallets,
  108. String? stakingOverviewLocked,
  109. bool? hideZeroBalanceInFundTab,
  110. bool? hideZeroBalanceInSpotTab,
  111. bool? obscureBalance,
  112. bool? isLoading,
  113. String? errorMessage,
  114. }) =>
  115. AssetState(
  116. todayPnl: todayPnl ?? this.todayPnl,
  117. positions: positions ?? this.positions,
  118. spotTradingTotal: spotTradingTotal ?? this.spotTradingTotal,
  119. spotTodayPnl: spotTodayPnl ?? this.spotTodayPnl,
  120. spotTodayPnlRate: spotTodayPnlRate ?? this.spotTodayPnlRate,
  121. spotWallets: spotWallets ?? this.spotWallets,
  122. fundWallets: fundWallets ?? this.fundWallets,
  123. stakingOverviewLocked:
  124. stakingOverviewLocked ?? this.stakingOverviewLocked,
  125. hideZeroBalanceInFundTab:
  126. hideZeroBalanceInFundTab ?? this.hideZeroBalanceInFundTab,
  127. hideZeroBalanceInSpotTab:
  128. hideZeroBalanceInSpotTab ?? this.hideZeroBalanceInSpotTab,
  129. obscureBalance: obscureBalance ?? this.obscureBalance,
  130. isLoading: isLoading ?? this.isLoading,
  131. errorMessage: errorMessage,
  132. );
  133. }
  134. class AssetNotifier extends AutoDisposeNotifier<AssetState> {
  135. Timer? _pollTimer;
  136. StreamSubscription<Map<String, dynamic>>? _spotAssetWsSub;
  137. bool _spotTradingTabHttpBootstrapped = false;
  138. bool _spotAssetChannelRetained = false;
  139. @override
  140. AssetState build() {
  141. // keepAlive 保证 provider 不因无 watcher 而销毁,同时 AutoDispose
  142. // 机制会在 ConsumerStatefulElement.deactivate() 时立即移除监听,
  143. // 避免 loadAssets async 回调命中已 defunct 的 element。
  144. ref.keepAlive();
  145. ref.onDispose(() {
  146. _pollTimer?.cancel();
  147. _teardownSpotAssetWs();
  148. });
  149. ref.listen<SpotWsClient>(spotWsClientProvider, (prev, next) {
  150. if (prev != null && !identical(prev, next)) {
  151. _spotAssetWsSub?.cancel();
  152. _spotAssetWsSub = null;
  153. _spotAssetChannelRetained = false;
  154. final onAssetPage = ref.read(activeBottomTabProvider) == 5;
  155. final onSpotSubTab = ref.read(currentAssetSubTabProvider) == 2;
  156. if (onAssetPage &&
  157. onSpotSubTab &&
  158. ref.read(isLoggedInProvider)) {
  159. Future.microtask(() => _ensureSpotAssetWs());
  160. }
  161. }
  162. });
  163. ref.listen<AsyncValue<SpotWsState>>(spotWsConnectionStateProvider,
  164. (prev, next) {
  165. final s = next.valueOrNull;
  166. if (s != SpotWsState.connected) return;
  167. if (!ref.read(isLoggedInProvider)) return;
  168. if (prev?.valueOrNull == SpotWsState.reconnecting) {
  169. Future.microtask(() => _loadSpotSliceFromHttp());
  170. }
  171. });
  172. ref.listen<bool>(isLoggedInProvider, (prev, loggedIn) {
  173. if (loggedIn) {
  174. _spotTradingTabHttpBootstrapped = false;
  175. Future.microtask(() {
  176. if (ref.read(activeBottomTabProvider) == 5 &&
  177. ref.read(currentAssetSubTabProvider) == 2) {
  178. onSpotTradingTabVisible();
  179. }
  180. });
  181. } else {
  182. _spotTradingTabHttpBootstrapped = false;
  183. _teardownSpotAssetWs();
  184. }
  185. });
  186. ref.listen<int>(currentAssetSubTabProvider, (prev, subTab) {
  187. if (ref.read(activeBottomTabProvider) != 5) return;
  188. if (subTab == 3) {
  189. startPositionPolling();
  190. } else {
  191. stopPositionPolling();
  192. }
  193. if (subTab == 2 && ref.read(isLoggedInProvider)) {
  194. Future.microtask(() => onSpotTradingTabVisible());
  195. } else if (prev == 2) {
  196. onSpotTradingTabHidden();
  197. }
  198. });
  199. ref.listen<int>(activeBottomTabProvider, (prev, tabIndex) {
  200. if (tabIndex != 5) {
  201. stopPositionPolling();
  202. onSpotTradingTabHidden();
  203. } else {
  204. final subTab = ref.read(currentAssetSubTabProvider);
  205. if (subTab == 3) {
  206. startPositionPolling();
  207. }
  208. if (subTab == 2 && ref.read(isLoggedInProvider)) {
  209. Future.microtask(() => onSpotTradingTabVisible());
  210. }
  211. }
  212. });
  213. Future.microtask(loadAssets);
  214. return const AssetState(isLoading: true);
  215. }
  216. Future<void> onSpotTradingTabVisible() async {
  217. if (!ref.read(isLoggedInProvider)) return;
  218. if (!_spotTradingTabHttpBootstrapped) {
  219. _spotTradingTabHttpBootstrapped = true;
  220. await _loadSpotSliceFromHttp();
  221. }
  222. _ensureSpotAssetWs();
  223. }
  224. void onSpotTradingTabHidden() => _teardownSpotAssetWs();
  225. Future<void> _loadSpotSliceFromHttp() async {
  226. if (!ref.read(isLoggedInProvider)) return;
  227. try {
  228. final dio = ref.read(dioClientProvider);
  229. final spotData = await SpotService(dio)
  230. .getAssets(hideZero: state.hideZeroBalanceInSpotTab);
  231. final spotTotal = _toDouble(spotData['totalAmount']);
  232. final spotPnl = _toDouble(spotData['todayPnl']);
  233. final spotPnlRate = _toDouble(spotData['todayPnlRate']);
  234. final spotWallets = _parseSpotWallets(spotData);
  235. state = state.copyWith(
  236. spotTradingTotal: spotTotal,
  237. spotTodayPnl: spotPnl,
  238. spotTodayPnlRate: spotPnlRate,
  239. spotWallets: spotWallets,
  240. );
  241. } catch (_) {}
  242. }
  243. Future<void> _loadFundSliceFromHttp() async {
  244. if (!ref.read(isLoggedInProvider)) return;
  245. try {
  246. final dio = ref.read(dioClientProvider);
  247. final fundData = await AssetService(dio)
  248. .getFundAssets(hideZero: state.hideZeroBalanceInFundTab);
  249. state = state.copyWith(fundWallets: _parseSpotWallets(fundData));
  250. } catch (_) {}
  251. }
  252. void _ensureSpotAssetWs() {
  253. if (!ref.read(isLoggedInProvider)) return;
  254. _spotAssetWsSub?.cancel();
  255. _spotAssetWsSub = null;
  256. final ws = ref.read(spotWsClientProvider);
  257. if (!_spotAssetChannelRetained) {
  258. ws.retainSpotAssetChannel();
  259. _spotAssetChannelRetained = true;
  260. }
  261. _spotAssetWsSub = ws.assetStream.listen(_onSpotAssetPush);
  262. }
  263. void _teardownSpotAssetWs() {
  264. _spotAssetWsSub?.cancel();
  265. _spotAssetWsSub = null;
  266. if (!_spotAssetChannelRetained) return;
  267. try {
  268. ref.read(spotWsClientProvider).releaseSpotAssetChannel();
  269. } catch (_) {}
  270. _spotAssetChannelRetained = false;
  271. }
  272. void _onSpotAssetPush(Map<String, dynamic> msg) {
  273. final list = msg['accountList'];
  274. if (list is! List) return;
  275. if (list.isEmpty) {
  276. Future.microtask(() => _loadSpotSliceFromHttp());
  277. return;
  278. }
  279. final merged = _mergeSpotWallets(state.spotWallets, list);
  280. state = state.copyWith(spotWallets: merged);
  281. }
  282. List<SpotWalletAsset> _mergeSpotWallets(
  283. List<SpotWalletAsset> current,
  284. List<dynamic> incoming,
  285. ) {
  286. final byCoin = <String, SpotWalletAsset>{
  287. for (final w in current) w.coin: w,
  288. };
  289. for (final raw in incoming) {
  290. if (raw is! Map) continue;
  291. final a = SpotWalletAsset.fromJson(Map<String, dynamic>.from(raw));
  292. if (a.coin.isEmpty) continue;
  293. byCoin[a.coin] = a;
  294. }
  295. final out = byCoin.values.toList()
  296. ..sort((a, b) => a.coin.compareTo(b.coin));
  297. return out;
  298. }
  299. void startPositionPolling() {
  300. _pollTimer?.cancel();
  301. _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) {
  302. if (ref.read(activeBottomTabProvider) != 5) {
  303. stopPositionPolling();
  304. return;
  305. }
  306. silentRefresh();
  307. });
  308. }
  309. void stopPositionPolling() {
  310. _pollTimer?.cancel();
  311. _pollTimer = null;
  312. }
  313. Future<void> loadAssets({bool silent = false}) async {
  314. if (!silent) state = state.copyWith(isLoading: true, errorMessage: null);
  315. final dio = ref.read(dioClientProvider);
  316. try {
  317. final results = await Future.wait([
  318. AssetService(dio).getTodayPnl().catchError((_) => TodayPnl()),
  319. FuturesService(dio)
  320. .getWithPositions()
  321. .catchError((_) => <String, dynamic>{}),
  322. SpotService(dio)
  323. .getAssets(hideZero: state.hideZeroBalanceInSpotTab)
  324. .catchError((_) => <String, dynamic>{}),
  325. AssetService(dio)
  326. .getFundAssets(hideZero: state.hideZeroBalanceInFundTab)
  327. .catchError((_) => <String, dynamic>{}),
  328. StakingService(dio)
  329. .getStakingWalletBalance('IBIT')
  330. .catchError((_) => StakingWalletBalance.empty('IBIT')),
  331. ]);
  332. final todayPnl = results[0] as TodayPnl;
  333. final posData = results[1] as Map<String, dynamic>;
  334. final rawPositions =
  335. (posData['currentPositionWithCutList'] as List<dynamic>? ?? [])
  336. .cast<Map<String, dynamic>>();
  337. final positions = rawPositions
  338. .map((e) {
  339. try {
  340. return FuturesPosition.fromJson(e);
  341. } catch (_) {
  342. return null;
  343. }
  344. })
  345. .whereType<FuturesPosition>()
  346. .toList();
  347. final spotData = results[2] as Map<String, dynamic>;
  348. final spotTotal = _toDouble(spotData['totalAmount']);
  349. final spotPnl = _toDouble(spotData['todayPnl']);
  350. final spotPnlRate = _toDouble(spotData['todayPnlRate']);
  351. final rawAssetList = spotData['assetList'];
  352. final spotWallets = rawAssetList is List
  353. ? rawAssetList
  354. .whereType<Map<String, dynamic>>()
  355. .map(SpotWalletAsset.fromJson)
  356. .toList()
  357. : <SpotWalletAsset>[];
  358. final fundData = results[3] as Map<String, dynamic>;
  359. final fundWallets = _parseSpotWallets(fundData);
  360. final stakingWallet = results[4] as StakingWalletBalance;
  361. var stakingLocked =
  362. stakingOverviewLockedFromAccounts(todayPnl.accountInfoList);
  363. final apiLocked = stakingWallet.lockedBalance;
  364. if ((double.tryParse(stakingLocked) ?? 0) <= 0 &&
  365. (double.tryParse(apiLocked) ?? 0) > 0) {
  366. stakingLocked = apiLocked;
  367. }
  368. state = state.copyWith(
  369. todayPnl: todayPnl,
  370. positions: positions,
  371. spotTradingTotal: spotTotal,
  372. spotTodayPnl: spotPnl,
  373. spotTodayPnlRate: spotPnlRate,
  374. spotWallets: spotWallets,
  375. fundWallets: fundWallets,
  376. stakingOverviewLocked: stakingLocked,
  377. isLoading: false,
  378. );
  379. } catch (e) {
  380. if (!silent) {
  381. state = state.copyWith(isLoading: false, errorMessage: e.toString());
  382. }
  383. }
  384. }
  385. static double _toDouble(dynamic v) {
  386. if (v == null) return 0.0;
  387. if (v is num) return v.toDouble();
  388. return double.tryParse(v.toString()) ?? 0.0;
  389. }
  390. Future<void> refresh() => loadAssets();
  391. Future<void> silentRefresh() => loadAssets(silent: true);
  392. void toggleObscure() =>
  393. state = state.copyWith(obscureBalance: !state.obscureBalance);
  394. void toggleHideZeroBalanceInFundTab() {
  395. state = state.copyWith(
  396. hideZeroBalanceInFundTab: !state.hideZeroBalanceInFundTab,
  397. );
  398. Future.microtask(_loadFundSliceFromHttp);
  399. }
  400. void toggleHideZeroBalanceInSpotTab() {
  401. state = state.copyWith(
  402. hideZeroBalanceInSpotTab: !state.hideZeroBalanceInSpotTab,
  403. );
  404. Future.microtask(_loadSpotSliceFromHttp);
  405. }
  406. static List<SpotWalletAsset> _parseSpotWallets(Map<String, dynamic> payload) {
  407. final rawAssetList = payload['assetList'];
  408. if (rawAssetList is! List) {
  409. return <SpotWalletAsset>[];
  410. }
  411. return rawAssetList
  412. .whereType<Map<String, dynamic>>()
  413. .map(SpotWalletAsset.fromJson)
  414. .toList();
  415. }
  416. }
  417. final assetProvider = AutoDisposeNotifierProvider<AssetNotifier, AssetState>(
  418. AssetNotifier.new,
  419. );
  420. /// 资产页子 tab:0 总览 1 现货 2 合约…
  421. final currentAssetSubTabProvider = StateProvider<int>((ref) => 0);