staking_provider.dart 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. import 'dart:async';
  2. import 'package:decimal/decimal.dart';
  3. import 'package:flutter/foundation.dart';
  4. import 'package:flutter_riverpod/flutter_riverpod.dart';
  5. import '../core/network/dio_client.dart';
  6. import '../data/models/finance/airdrop_eligibility.dart';
  7. import '../data/models/finance/airdrop_record_item.dart';
  8. import '../data/models/finance/staking_config.dart';
  9. import '../data/models/finance/staking_wallet_balance.dart';
  10. import '../data/services/spot_service.dart';
  11. import '../data/services/staking_service.dart';
  12. import 'auth_provider.dart';
  13. Decimal _d(String v) => Decimal.tryParse(v) ?? Decimal.zero;
  14. class StakingState {
  15. final bool isLoadingConfig;
  16. final bool isRefreshingBalance;
  17. final bool isSubmitting;
  18. final bool isTransfering;
  19. final String? errorMessage;
  20. final List<StakingConfig> configs;
  21. final StakingConfig? selectedConfig;
  22. final String fundingAvailable;
  23. final StakingWalletBalance stakingWallet;
  24. final int teamNum;
  25. final String lockedTotal;
  26. final bool isLoadingOverview;
  27. const StakingState({
  28. this.isLoadingConfig = false,
  29. this.isRefreshingBalance = false,
  30. this.isSubmitting = false,
  31. this.isTransfering = false,
  32. this.errorMessage,
  33. this.configs = const [],
  34. this.selectedConfig,
  35. this.fundingAvailable = '0',
  36. this.stakingWallet = const StakingWalletBalance(
  37. coinUnit: '',
  38. availableBalance: '0',
  39. lockedBalance: '0',
  40. ),
  41. this.teamNum = 0,
  42. this.lockedTotal = '0',
  43. this.isLoadingOverview = false,
  44. });
  45. StakingState copyWith({
  46. bool? isLoadingConfig,
  47. bool? isRefreshingBalance,
  48. bool? isSubmitting,
  49. bool? isTransfering,
  50. String? errorMessage,
  51. List<StakingConfig>? configs,
  52. StakingConfig? selectedConfig,
  53. bool clearSelectedConfig = false,
  54. String? fundingAvailable,
  55. StakingWalletBalance? stakingWallet,
  56. int? teamNum,
  57. String? lockedTotal,
  58. bool? isLoadingOverview,
  59. }) {
  60. return StakingState(
  61. isLoadingConfig: isLoadingConfig ?? this.isLoadingConfig,
  62. isRefreshingBalance: isRefreshingBalance ?? this.isRefreshingBalance,
  63. isSubmitting: isSubmitting ?? this.isSubmitting,
  64. isTransfering: isTransfering ?? this.isTransfering,
  65. errorMessage: errorMessage,
  66. configs: configs ?? this.configs,
  67. selectedConfig:
  68. clearSelectedConfig ? null : (selectedConfig ?? this.selectedConfig),
  69. fundingAvailable: fundingAvailable ?? this.fundingAvailable,
  70. stakingWallet: stakingWallet ?? this.stakingWallet,
  71. teamNum: teamNum ?? this.teamNum,
  72. lockedTotal: lockedTotal ?? this.lockedTotal,
  73. isLoadingOverview: isLoadingOverview ?? this.isLoadingOverview,
  74. );
  75. }
  76. }
  77. class StakingNotifier extends AutoDisposeNotifier<StakingState> {
  78. StakingService get _stakingService =>
  79. StakingService(ref.read(dioClientProvider));
  80. SpotService get _spotService => SpotService(ref.read(dioClientProvider));
  81. @override
  82. StakingState build() => const StakingState();
  83. Future<void> init({String? configId}) async {
  84. state = state.copyWith(isLoadingConfig: true, errorMessage: null);
  85. try {
  86. StakingConfig? selected;
  87. List<StakingConfig> list = const [];
  88. if (configId != null && configId.isNotEmpty) {
  89. list = await _stakingService.getConfigs();
  90. final id = int.tryParse(configId);
  91. if (id != null) {
  92. for (final item in list) {
  93. if (item.id == id) {
  94. selected = item;
  95. break;
  96. }
  97. }
  98. }
  99. selected ??= await _stakingService.getMinOpenConfig();
  100. } else {
  101. final results = await Future.wait([
  102. _stakingService.getConfigs(),
  103. _stakingService.getMinOpenConfig(),
  104. ]);
  105. list = results[0] as List<StakingConfig>;
  106. selected = results[1] as StakingConfig?;
  107. if (selected == null && list.isNotEmpty) {
  108. selected ??= list.first;
  109. }
  110. }
  111. if (selected == null) {
  112. state = state.copyWith(
  113. isLoadingConfig: false,
  114. configs: list,
  115. clearSelectedConfig: true,
  116. errorMessage: 'No staking config available',
  117. );
  118. return;
  119. }
  120. state = state.copyWith(
  121. configs: list,
  122. selectedConfig: selected,
  123. isLoadingConfig: false,
  124. );
  125. unawaited(_refreshWithdrawableFund());
  126. unawaited(refreshOverviewStats());
  127. } catch (e) {
  128. state = state.copyWith(isLoadingConfig: false, errorMessage: '$e');
  129. }
  130. }
  131. Future<void> refreshBalances() async {
  132. await Future.wait([
  133. _refreshWithdrawableFund(),
  134. refreshOverviewStats(),
  135. ]);
  136. }
  137. /// 对齐 Web `refreshOverviewStats`:团队人数 + 锁仓总量
  138. Future<void> refreshOverviewStats() async {
  139. final config = state.selectedConfig;
  140. final rawUnit = config?.coinUnit.trim() ?? '';
  141. final coinUnit =
  142. rawUnit.isEmpty ? StakingService.stakingCoinUnit : rawUnit;
  143. state = state.copyWith(isLoadingOverview: true);
  144. try {
  145. final team = await _stakingService.getTeamOverview();
  146. var locked = team.lockTotal;
  147. if (locked <= 0) {
  148. try {
  149. final wallet = await _stakingService.getStakingWalletBalance(coinUnit);
  150. final available = double.tryParse(wallet.availableBalance) ?? 0;
  151. final lockedBal = double.tryParse(wallet.lockedBalance) ?? 0;
  152. locked = available + lockedBal;
  153. } catch (_) {
  154. locked = 0;
  155. }
  156. }
  157. state = state.copyWith(
  158. teamNum: team.teamNum,
  159. lockedTotal: '$locked',
  160. isLoadingOverview: false,
  161. );
  162. } catch (_) {
  163. try {
  164. final wallet = await _stakingService.getStakingWalletBalance(coinUnit);
  165. final available = double.tryParse(wallet.availableBalance) ?? 0;
  166. final lockedBal = double.tryParse(wallet.lockedBalance) ?? 0;
  167. state = state.copyWith(
  168. lockedTotal: '${available + lockedBal}',
  169. isLoadingOverview: false,
  170. );
  171. } catch (__) {
  172. state = state.copyWith(
  173. teamNum: 0,
  174. lockedTotal: '0',
  175. isLoadingOverview: false,
  176. );
  177. }
  178. }
  179. }
  180. Future<void> selectConfig(StakingConfig config) async {
  181. state = state.copyWith(selectedConfig: config, errorMessage: null);
  182. await _refreshWithdrawableFund();
  183. }
  184. /// 对齐 Web `refreshWithdrawableIbit`:仅拉可提现账户余额
  185. Future<void> _refreshWithdrawableFund() async {
  186. final config = state.selectedConfig;
  187. final rawUnit = config?.coinUnit.trim() ?? '';
  188. final coinUnit = rawUnit.isEmpty ? StakingService.stakingCoinUnit : rawUnit;
  189. state = state.copyWith(isRefreshingBalance: true);
  190. try {
  191. final available =
  192. await _stakingService.getFundingWalletAvailable(coinUnit);
  193. state = state.copyWith(
  194. fundingAvailable: available,
  195. isRefreshingBalance: false,
  196. );
  197. } catch (_) {
  198. state = state.copyWith(
  199. fundingAvailable: '0',
  200. isRefreshingBalance: false,
  201. );
  202. }
  203. }
  204. String? validateAmount(String amount) {
  205. final config = state.selectedConfig;
  206. if (config == null) return 'errServiceUnavailable';
  207. final input = _d(amount);
  208. if (input <= Decimal.zero) return 'errEnterAmount';
  209. final minAmount = _d(config.minAmount);
  210. if (input < minAmount) return 'errAmountBelowMin:${config.minAmount}';
  211. final maxAmount = _d(config.maxAmount);
  212. if (maxAmount > Decimal.zero && input > maxAmount) {
  213. return 'errAmountAboveMax:${config.maxAmount}';
  214. }
  215. final available = _d(state.fundingAvailable);
  216. if (input > available) return 'errExceedBalance';
  217. return null;
  218. }
  219. Future<String?> submitStake(String amount) async {
  220. final config = state.selectedConfig;
  221. if (config == null) return 'errServiceUnavailable';
  222. if (!ref.read(isLoggedInProvider)) return 'errNeedLogin';
  223. final validation = validateAmount(amount);
  224. if (validation != null) return validation;
  225. state = state.copyWith(isSubmitting: true, errorMessage: null);
  226. try {
  227. await _stakingService.submitStake(configId: config.id, amount: amount);
  228. await Future.wait([
  229. _refreshWithdrawableFund(),
  230. refreshOverviewStats(),
  231. ]);
  232. state = state.copyWith(isSubmitting: false);
  233. return null;
  234. } catch (e) {
  235. state = state.copyWith(isSubmitting: false, errorMessage: '$e');
  236. return '$e';
  237. }
  238. }
  239. Future<String?> transfer({
  240. required int direction,
  241. required String amount,
  242. }) async {
  243. final config = state.selectedConfig;
  244. if (config == null) return 'errServiceUnavailable';
  245. if (!ref.read(isLoggedInProvider)) return 'errNeedLogin';
  246. final val = _d(amount);
  247. if (val <= Decimal.zero) return 'errEnterAmount';
  248. state = state.copyWith(isTransfering: true, errorMessage: null);
  249. try {
  250. await _spotService.transfer(
  251. symbol: config.coinUnit,
  252. amount: val.toDouble(),
  253. direction: direction,
  254. );
  255. await _refreshWithdrawableFund();
  256. state = state.copyWith(isTransfering: false);
  257. return null;
  258. } catch (e) {
  259. state = state.copyWith(isTransfering: false, errorMessage: '$e');
  260. return '$e';
  261. }
  262. }
  263. }
  264. class AirdropState {
  265. final bool isLoadingEligibility;
  266. final bool isLoadingRecords;
  267. final bool isClaiming;
  268. final bool isLoadingMore;
  269. final String? errorMessage;
  270. final AirdropEligibility eligibility;
  271. final List<AirdropRecordItem> records;
  272. final int pageNo;
  273. final int totalPages;
  274. const AirdropState({
  275. this.isLoadingEligibility = false,
  276. this.isLoadingRecords = false,
  277. this.isClaiming = false,
  278. this.isLoadingMore = false,
  279. this.errorMessage,
  280. this.eligibility = const AirdropEligibility(
  281. inviteCount: 0,
  282. requiredInviteCount: 3,
  283. inviteTaskCompleted: false,
  284. hasActiveStaking: false,
  285. hasPendingAirdrop: false,
  286. claimableAmount: '0',
  287. eligible: false,
  288. message: '',
  289. ),
  290. this.records = const [],
  291. this.pageNo = 1,
  292. this.totalPages = 0,
  293. });
  294. bool get hasMore => totalPages > pageNo;
  295. AirdropState copyWith({
  296. bool? isLoadingEligibility,
  297. bool? isLoadingRecords,
  298. bool? isClaiming,
  299. bool? isLoadingMore,
  300. String? errorMessage,
  301. AirdropEligibility? eligibility,
  302. List<AirdropRecordItem>? records,
  303. int? pageNo,
  304. int? totalPages,
  305. }) {
  306. return AirdropState(
  307. isLoadingEligibility: isLoadingEligibility ?? this.isLoadingEligibility,
  308. isLoadingRecords: isLoadingRecords ?? this.isLoadingRecords,
  309. isClaiming: isClaiming ?? this.isClaiming,
  310. isLoadingMore: isLoadingMore ?? this.isLoadingMore,
  311. errorMessage: errorMessage,
  312. eligibility: eligibility ?? this.eligibility,
  313. records: records ?? this.records,
  314. pageNo: pageNo ?? this.pageNo,
  315. totalPages: totalPages ?? this.totalPages,
  316. );
  317. }
  318. }
  319. class AirdropNotifier extends AutoDisposeNotifier<AirdropState> {
  320. StakingService get _stakingService =>
  321. StakingService(ref.read(dioClientProvider));
  322. void _log(String message) {
  323. if (kDebugMode) {
  324. debugPrint('[Airdrop][Provider] $message');
  325. }
  326. }
  327. @override
  328. AirdropState build() => const AirdropState();
  329. Future<void> init() async {
  330. final isLoggedIn = ref.read(isLoggedInProvider);
  331. _log('init start isLoggedIn=$isLoggedIn');
  332. state = state.copyWith(
  333. isLoadingEligibility: isLoggedIn,
  334. isLoadingRecords: isLoggedIn,
  335. errorMessage: null,
  336. );
  337. if (!isLoggedIn) {
  338. state = state.copyWith(
  339. eligibility: AirdropEligibility.empty(),
  340. records: const [],
  341. pageNo: 1,
  342. totalPages: 0,
  343. isLoadingEligibility: false,
  344. isLoadingRecords: false,
  345. );
  346. _log('init stop for guest user');
  347. return;
  348. }
  349. // 并行请求,收集结果后一次性更新 state,避免多次 rebuild
  350. AirdropEligibility? eligibilityResult;
  351. AirdropRecordPage? recordResult;
  352. String? loadError;
  353. await Future.wait([
  354. _stakingService.getAirdropEligibility().then(
  355. (e) {
  356. eligibilityResult = e;
  357. },
  358. ).catchError((e, st) {
  359. loadError = '$e';
  360. _log('eligibility request failed: $e');
  361. _log('$st');
  362. }),
  363. _stakingService.getAirdropRecords(pageNo: 1, pageSize: 10).then(
  364. (p) {
  365. recordResult = p;
  366. },
  367. ).catchError((e, st) {
  368. loadError ??= '$e';
  369. _log('records request failed: $e');
  370. _log('$st');
  371. }),
  372. ]);
  373. state = state.copyWith(
  374. eligibility: eligibilityResult ?? AirdropEligibility.empty(),
  375. records: recordResult?.content ?? const [],
  376. pageNo: recordResult?.pageNo ?? 1,
  377. totalPages: recordResult?.totalPages ?? 0,
  378. isLoadingEligibility: false,
  379. isLoadingRecords: false,
  380. errorMessage: loadError,
  381. );
  382. _log(
  383. 'init done eligible=${state.eligibility.eligible} records=${state.records.length} pageNo=${state.pageNo} totalPages=${state.totalPages} error=${state.errorMessage}',
  384. );
  385. }
  386. Future<String?> claim() async {
  387. if (!ref.read(isLoggedInProvider)) return 'errNeedLogin';
  388. if (!state.eligibility.eligible) return state.eligibility.message;
  389. state = state.copyWith(isClaiming: true, errorMessage: null);
  390. try {
  391. await _stakingService.claimAirdrop();
  392. await init();
  393. state = state.copyWith(isClaiming: false);
  394. return null;
  395. } catch (e) {
  396. state = state.copyWith(isClaiming: false, errorMessage: '$e');
  397. return '$e';
  398. }
  399. }
  400. Future<void> loadMore() async {
  401. if (state.isLoadingMore ||
  402. state.isLoadingRecords ||
  403. !state.hasMore ||
  404. !ref.read(isLoggedInProvider)) {
  405. return;
  406. }
  407. state = state.copyWith(isLoadingMore: true);
  408. _log(
  409. 'loadMore start currentPage=${state.pageNo} totalPages=${state.totalPages}');
  410. try {
  411. final nextPageNo = state.pageNo + 1;
  412. final page = await _stakingService.getAirdropRecords(
  413. pageNo: nextPageNo,
  414. pageSize: 10,
  415. );
  416. state = state.copyWith(
  417. isLoadingMore: false,
  418. records: [...state.records, ...page.content],
  419. pageNo: page.pageNo,
  420. totalPages: page.totalPages,
  421. );
  422. _log(
  423. 'loadMore success nextPage=${page.pageNo} append=${page.content.length} totalRecords=${state.records.length}',
  424. );
  425. } catch (e) {
  426. state = state.copyWith(isLoadingMore: false, errorMessage: '$e');
  427. _log('loadMore failed: $e');
  428. }
  429. }
  430. }
  431. final stakingProvider =
  432. AutoDisposeNotifierProvider<StakingNotifier, StakingState>(
  433. StakingNotifier.new,
  434. );
  435. final airdropProvider =
  436. AutoDisposeNotifierProvider<AirdropNotifier, AirdropState>(
  437. AirdropNotifier.new,
  438. );