asset_provider.dart 19 KB

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