asset_provider.dart 18 KB

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