import 'dart:async'; import 'package:decimal/decimal.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/network/dio_client.dart'; import '../data/models/finance/airdrop_eligibility.dart'; import '../data/models/finance/airdrop_record_item.dart'; import '../data/models/finance/staking_config.dart'; import '../data/models/finance/staking_wallet_balance.dart'; import '../data/services/spot_service.dart'; import '../data/services/staking_service.dart'; import 'auth_provider.dart'; Decimal _d(String v) => Decimal.tryParse(v) ?? Decimal.zero; class StakingState { final bool isLoadingConfig; final bool isRefreshingBalance; final bool isSubmitting; final bool isTransfering; final String? errorMessage; final List configs; final StakingConfig? selectedConfig; final String fundingAvailable; final StakingWalletBalance stakingWallet; const StakingState({ this.isLoadingConfig = false, this.isRefreshingBalance = false, this.isSubmitting = false, this.isTransfering = false, this.errorMessage, this.configs = const [], this.selectedConfig, this.fundingAvailable = '0', this.stakingWallet = const StakingWalletBalance( coinUnit: '', availableBalance: '0', lockedBalance: '0', ), }); StakingState copyWith({ bool? isLoadingConfig, bool? isRefreshingBalance, bool? isSubmitting, bool? isTransfering, String? errorMessage, List? configs, StakingConfig? selectedConfig, bool clearSelectedConfig = false, String? fundingAvailable, StakingWalletBalance? stakingWallet, }) { return StakingState( isLoadingConfig: isLoadingConfig ?? this.isLoadingConfig, isRefreshingBalance: isRefreshingBalance ?? this.isRefreshingBalance, isSubmitting: isSubmitting ?? this.isSubmitting, isTransfering: isTransfering ?? this.isTransfering, errorMessage: errorMessage, configs: configs ?? this.configs, selectedConfig: clearSelectedConfig ? null : (selectedConfig ?? this.selectedConfig), fundingAvailable: fundingAvailable ?? this.fundingAvailable, stakingWallet: stakingWallet ?? this.stakingWallet, ); } } class StakingNotifier extends AutoDisposeNotifier { StakingService get _stakingService => StakingService(ref.read(dioClientProvider)); SpotService get _spotService => SpotService(ref.read(dioClientProvider)); @override StakingState build() => const StakingState(); Future init({String? configId}) async { state = state.copyWith(isLoadingConfig: true, errorMessage: null); try { StakingConfig? selected; List list = const []; if (configId != null && configId.isNotEmpty) { list = await _stakingService.getConfigs(); final id = int.tryParse(configId); if (id != null) { for (final item in list) { if (item.id == id) { selected = item; break; } } } selected ??= await _stakingService.getMinOpenConfig(); } else { final results = await Future.wait([ _stakingService.getConfigs(), _stakingService.getMinOpenConfig(), ]); list = results[0] as List; selected = results[1] as StakingConfig?; if (selected == null && list.isNotEmpty) { selected ??= list.first; } } if (selected == null) { state = state.copyWith( isLoadingConfig: false, configs: list, clearSelectedConfig: true, errorMessage: 'No staking config available', ); return; } state = state.copyWith( configs: list, selectedConfig: selected, isLoadingConfig: false, ); unawaited(_refreshWithdrawableFund()); } catch (e) { state = state.copyWith(isLoadingConfig: false, errorMessage: '$e'); } } Future refreshBalances() => _refreshWithdrawableFund(); Future selectConfig(StakingConfig config) async { state = state.copyWith(selectedConfig: config, errorMessage: null); await _refreshWithdrawableFund(); } /// 对齐 Web `refreshWithdrawableIbit`:仅拉可提现账户余额 Future _refreshWithdrawableFund() async { final config = state.selectedConfig; final rawUnit = config?.coinUnit.trim() ?? ''; final coinUnit = rawUnit.isEmpty ? StakingService.stakingCoinUnit : rawUnit; state = state.copyWith(isRefreshingBalance: true); try { final available = await _stakingService.getFundingWalletAvailable(coinUnit); state = state.copyWith( fundingAvailable: available, isRefreshingBalance: false, ); } catch (_) { state = state.copyWith( fundingAvailable: '0', isRefreshingBalance: false, ); } } String? validateAmount(String amount) { final config = state.selectedConfig; if (config == null) return 'errServiceUnavailable'; final input = _d(amount); if (input <= Decimal.zero) return 'errEnterAmount'; final minAmount = _d(config.minAmount); if (input < minAmount) return 'errAmountBelowMin:${config.minAmount}'; final maxAmount = _d(config.maxAmount); if (maxAmount > Decimal.zero && input > maxAmount) { return 'errAmountAboveMax:${config.maxAmount}'; } final available = _d(state.fundingAvailable); if (input > available) return 'errExceedBalance'; return null; } Future submitStake(String amount) async { final config = state.selectedConfig; if (config == null) return 'errServiceUnavailable'; if (!ref.read(isLoggedInProvider)) return 'errNeedLogin'; final validation = validateAmount(amount); if (validation != null) return validation; state = state.copyWith(isSubmitting: true, errorMessage: null); try { await _stakingService.submitStake(configId: config.id, amount: amount); await _refreshWithdrawableFund(); state = state.copyWith(isSubmitting: false); return null; } catch (e) { state = state.copyWith(isSubmitting: false, errorMessage: '$e'); return '$e'; } } Future transfer({ required int direction, required String amount, }) async { final config = state.selectedConfig; if (config == null) return 'errServiceUnavailable'; if (!ref.read(isLoggedInProvider)) return 'errNeedLogin'; final val = _d(amount); if (val <= Decimal.zero) return 'errEnterAmount'; state = state.copyWith(isTransfering: true, errorMessage: null); try { await _spotService.transfer( symbol: config.coinUnit, amount: val.toDouble(), direction: direction, ); await _refreshWithdrawableFund(); state = state.copyWith(isTransfering: false); return null; } catch (e) { state = state.copyWith(isTransfering: false, errorMessage: '$e'); return '$e'; } } } class AirdropState { final bool isLoadingEligibility; final bool isLoadingRecords; final bool isClaiming; final bool isLoadingMore; final String? errorMessage; final AirdropEligibility eligibility; final List records; final int pageNo; final int totalPages; const AirdropState({ this.isLoadingEligibility = false, this.isLoadingRecords = false, this.isClaiming = false, this.isLoadingMore = false, this.errorMessage, this.eligibility = const AirdropEligibility( inviteCount: 0, requiredInviteCount: 3, inviteTaskCompleted: false, hasActiveStaking: false, hasPendingAirdrop: false, claimableAmount: '0', eligible: false, message: '', ), this.records = const [], this.pageNo = 1, this.totalPages = 0, }); bool get hasMore => totalPages > pageNo; AirdropState copyWith({ bool? isLoadingEligibility, bool? isLoadingRecords, bool? isClaiming, bool? isLoadingMore, String? errorMessage, AirdropEligibility? eligibility, List? records, int? pageNo, int? totalPages, }) { return AirdropState( isLoadingEligibility: isLoadingEligibility ?? this.isLoadingEligibility, isLoadingRecords: isLoadingRecords ?? this.isLoadingRecords, isClaiming: isClaiming ?? this.isClaiming, isLoadingMore: isLoadingMore ?? this.isLoadingMore, errorMessage: errorMessage, eligibility: eligibility ?? this.eligibility, records: records ?? this.records, pageNo: pageNo ?? this.pageNo, totalPages: totalPages ?? this.totalPages, ); } } class AirdropNotifier extends AutoDisposeNotifier { StakingService get _stakingService => StakingService(ref.read(dioClientProvider)); void _log(String message) { if (kDebugMode) { debugPrint('[Airdrop][Provider] $message'); } } @override AirdropState build() => const AirdropState(); Future init() async { final isLoggedIn = ref.read(isLoggedInProvider); _log('init start isLoggedIn=$isLoggedIn'); state = state.copyWith( isLoadingEligibility: isLoggedIn, isLoadingRecords: isLoggedIn, errorMessage: null, ); if (!isLoggedIn) { state = state.copyWith( eligibility: AirdropEligibility.empty(), records: const [], pageNo: 1, totalPages: 0, isLoadingEligibility: false, isLoadingRecords: false, ); _log('init stop for guest user'); return; } // 并行请求,收集结果后一次性更新 state,避免多次 rebuild AirdropEligibility? eligibilityResult; AirdropRecordPage? recordResult; String? loadError; await Future.wait([ _stakingService.getAirdropEligibility().then( (e) { eligibilityResult = e; }, ).catchError((e, st) { loadError = '$e'; _log('eligibility request failed: $e'); _log('$st'); }), _stakingService.getAirdropRecords(pageNo: 1, pageSize: 10).then( (p) { recordResult = p; }, ).catchError((e, st) { loadError ??= '$e'; _log('records request failed: $e'); _log('$st'); }), ]); state = state.copyWith( eligibility: eligibilityResult ?? AirdropEligibility.empty(), records: recordResult?.content ?? const [], pageNo: recordResult?.pageNo ?? 1, totalPages: recordResult?.totalPages ?? 0, isLoadingEligibility: false, isLoadingRecords: false, errorMessage: loadError, ); _log( 'init done eligible=${state.eligibility.eligible} records=${state.records.length} pageNo=${state.pageNo} totalPages=${state.totalPages} error=${state.errorMessage}', ); } Future claim() async { if (!ref.read(isLoggedInProvider)) return 'errNeedLogin'; if (!state.eligibility.eligible) return state.eligibility.message; state = state.copyWith(isClaiming: true, errorMessage: null); try { await _stakingService.claimAirdrop(); await init(); state = state.copyWith(isClaiming: false); return null; } catch (e) { state = state.copyWith(isClaiming: false, errorMessage: '$e'); return '$e'; } } Future loadMore() async { if (state.isLoadingMore || state.isLoadingRecords || !state.hasMore || !ref.read(isLoggedInProvider)) { return; } state = state.copyWith(isLoadingMore: true); _log( 'loadMore start currentPage=${state.pageNo} totalPages=${state.totalPages}'); try { final nextPageNo = state.pageNo + 1; final page = await _stakingService.getAirdropRecords( pageNo: nextPageNo, pageSize: 10, ); state = state.copyWith( isLoadingMore: false, records: [...state.records, ...page.content], pageNo: page.pageNo, totalPages: page.totalPages, ); _log( 'loadMore success nextPage=${page.pageNo} append=${page.content.length} totalRecords=${state.records.length}', ); } catch (e) { state = state.copyWith(isLoadingMore: false, errorMessage: '$e'); _log('loadMore failed: $e'); } } } final stakingProvider = AutoDisposeNotifierProvider( StakingNotifier.new, ); final airdropProvider = AutoDisposeNotifierProvider( AirdropNotifier.new, );