import 'package:decimal/decimal.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/network/dio_client.dart'; import '../core/utils/dialog_utils.dart'; import '../core/utils/spot_transfer_asset.dart'; import '../core/utils/transfer_pair.dart'; import '../data/models/asset/transfer_record.dart'; import '../data/models/asset/today_pnl.dart'; import '../data/services/asset_service.dart'; import '../data/services/spot_service.dart'; class TransferInitOptions { const TransferInitOptions({ this.from, this.to, this.defaultSymbol = 'USDT', this.preferDefaultSymbol = false, this.spotTradingBridgeOnly = false, }); final String? from; final String? to; final String defaultSymbol; final bool preferDefaultSymbol; final bool spotTradingBridgeOnly; } class TransferState { final String fromType; final String toType; final bool spotTradingBridgeOnly; final bool preferDefaultSymbol; final List openCoins; final Map fundAvailable; final Map spotAvailable; final Map legacyBalances; final String spotTradingUsdtBalance; final String selectedCoin; final bool isLoading; final bool isSubmitting; final bool isLoadingCoins; final String? errorMessage; const TransferState({ this.fromType = kWalletSpot, this.toType = kWalletSwap, this.spotTradingBridgeOnly = false, this.preferDefaultSymbol = false, this.openCoins = const ['USDT'], this.fundAvailable = const {}, this.spotAvailable = const {}, this.legacyBalances = const {}, this.spotTradingUsdtBalance = '0', this.selectedCoin = 'USDT', this.isLoading = false, this.isSubmitting = false, this.isLoadingCoins = false, this.errorMessage, }); bool get isSpotCoinTransfer => involvesSpotTradingBridge(fromType, toType); String get displayCoinUnit => isSpotCoinTransfer ? selectedCoin : 'USDT'; List get transferOtherTypes { if (spotTradingBridgeOnly) { return const [kWalletSpotTrading]; } return kOtherWalletTypes; } Decimal get fromBalance { if (isSpotCoinTransfer) { if (fromType == kWalletSpotTrading) { return _d(spotAvailable[selectedCoin.toUpperCase()] ?? '0'); } if (fromType == kWalletSpot) { return _d(fundAvailable[selectedCoin.toUpperCase()] ?? '0'); } } if (fromType == kWalletSpotTrading) { return _d(spotTradingUsdtBalance); } return _d(legacyBalances[fromType] ?? '0'); } TransferState copyWith({ String? fromType, String? toType, bool? spotTradingBridgeOnly, bool? preferDefaultSymbol, List? openCoins, Map? fundAvailable, Map? spotAvailable, Map? legacyBalances, String? spotTradingUsdtBalance, String? selectedCoin, bool? isLoading, bool? isSubmitting, bool? isLoadingCoins, String? errorMessage, }) { return TransferState( fromType: fromType ?? this.fromType, toType: toType ?? this.toType, spotTradingBridgeOnly: spotTradingBridgeOnly ?? this.spotTradingBridgeOnly, preferDefaultSymbol: preferDefaultSymbol ?? this.preferDefaultSymbol, openCoins: openCoins ?? this.openCoins, fundAvailable: fundAvailable ?? this.fundAvailable, spotAvailable: spotAvailable ?? this.spotAvailable, legacyBalances: legacyBalances ?? this.legacyBalances, spotTradingUsdtBalance: spotTradingUsdtBalance ?? this.spotTradingUsdtBalance, selectedCoin: selectedCoin ?? this.selectedCoin, isLoading: isLoading ?? this.isLoading, isSubmitting: isSubmitting ?? this.isSubmitting, isLoadingCoins: isLoadingCoins ?? this.isLoadingCoins, errorMessage: errorMessage, ); } } Decimal _d(String v) => Decimal.tryParse(v) ?? Decimal.zero; class TransferNotifier extends AutoDisposeNotifier { TransferInitOptions _options = const TransferInitOptions(); bool _initialized = false; @override TransferState build() => const TransferState(); Future init(TransferInitOptions options) async { _options = options; final pair = options.spotTradingBridgeOnly ? normalizeTransferPair( options.from ?? kWalletSpot, options.to ?? kWalletSpotTrading, ) : normalizeTransferPair( options.from ?? kWalletSpot, options.to ?? kWalletSwap, ); state = state.copyWith( fromType: pair.$1, toType: pair.$2, spotTradingBridgeOnly: options.spotTradingBridgeOnly, preferDefaultSymbol: options.preferDefaultSymbol, selectedCoin: options.defaultSymbol.toUpperCase(), isLoading: true, errorMessage: null, ); await _loadLegacyBalances(); if (state.isSpotCoinTransfer || options.spotTradingBridgeOnly) { await _loadSpotTransferAssets(); } state = state.copyWith(isLoading: false); _initialized = true; } Future _loadLegacyBalances() async { try { final dio = ref.read(dioClientProvider); final assetService = AssetService(dio); final spotService = SpotService(dio); final results = await Future.wait([ assetService.getTodayPnl(), spotService.getAssets().catchError((_) => {}), ]); final todayPnl = results[0] as TodayPnl; final spotData = results[1] as Map; state = state.copyWith( legacyBalances: _legacyBalancesFromTodayPnl(todayPnl), spotTradingUsdtBalance: parseSpotAvailableMap(spotData)['USDT'] ?? '0', ); } catch (e) { state = state.copyWith(errorMessage: e.toString()); } } Map _legacyBalancesFromTodayPnl(TodayPnl todayPnl) { final list = todayPnl.accountInfoList; String balAt(int idx) { if (idx < 0 || idx >= list.length) { return '0'; } return list[idx].balance.toString(); } return { kWalletSwap: balAt(kSwapAccountIdx), kWalletFollow: balAt(kFollowAccountIdx), kWalletSpot: balAt(kSpotAccountIdx), }; } Future _loadSpotTransferAssets() async { state = state.copyWith(isLoadingCoins: true); try { final dio = ref.read(dioClientProvider); final spotService = SpotService(dio); final results = await Future.wait([ spotService.getCoins().catchError((_) => >[]), spotService.getAssets().catchError((_) => {}), dio .post>('uc/asset/wallet', data: {}) .then((r) => r.data) .catchError((_) => null), ]); final coinsRaw = results[0] as List>; final spotData = results[1] as Map; final fundRaw = results[2]; var openCoins = parseOpenCoinSymbols(coinsRaw); var selected = state.selectedCoin.toUpperCase(); if (_options.preferDefaultSymbol && openCoins.contains(_options.defaultSymbol.toUpperCase())) { selected = _options.defaultSymbol.toUpperCase(); } else if (!openCoins.contains(selected)) { selected = openCoins.contains('USDT') ? 'USDT' : openCoins.first; } state = state.copyWith( openCoins: openCoins, selectedCoin: selected, spotAvailable: parseSpotAvailableMap(spotData), fundAvailable: parseFundWalletBalances(fundRaw), spotTradingUsdtBalance: parseSpotAvailableMap(spotData)['USDT'] ?? state.spotTradingUsdtBalance, isLoadingCoins: false, ); } catch (e) { state = state.copyWith(isLoadingCoins: false, errorMessage: e.toString()); } } Future refresh() async { if (!_initialized) { return; } await _loadLegacyBalances(); if (state.isSpotCoinTransfer || state.spotTradingBridgeOnly) { await _loadSpotTransferAssets(); } } void selectCoin(String coin) { state = state.copyWith(selectedCoin: coin.toUpperCase()); } void setFromType(String type) { final pair = normalizeTransferPair(type, state.toType); state = state.copyWith(fromType: pair.$1, toType: pair.$2); _maybeLoadSpotAssets(); } void setToType(String type) { final pair = normalizeTransferPair(state.fromType, type); state = state.copyWith(fromType: pair.$1, toType: pair.$2); _maybeLoadSpotAssets(); } void _maybeLoadSpotAssets() { if (state.isSpotCoinTransfer) { Future.microtask(_loadSpotTransferAssets); } } void swapAccounts() { if (state.spotTradingBridgeOnly) { final nextFrom = state.fromType == kWalletSpot ? kWalletSpotTrading : kWalletSpot; final nextTo = nextFrom == kWalletSpot ? kWalletSpotTrading : kWalletSpot; state = state.copyWith(fromType: nextFrom, toType: nextTo); Future.microtask(_loadSpotTransferAssets); return; } final pair = normalizeTransferPair(state.toType, state.fromType); state = state.copyWith(fromType: pair.$1, toType: pair.$2); _maybeLoadSpotAssets(); } Future submit(String amount) async { final pair = normalizeTransferPair(state.fromType, state.toType); state = state.copyWith(fromType: pair.$1, toType: pair.$2); if (pair.$1 == pair.$2) { state = state.copyWith(errorMessage: 'errSameAccount'); return false; } final input = _d(amount); if (input <= Decimal.zero) { state = state.copyWith(errorMessage: 'errEnterAmount'); return false; } if (input > state.fromBalance) { state = state.copyWith(errorMessage: 'errExceedBalance'); return false; } state = state.copyWith(isSubmitting: true, errorMessage: null); try { final dio = ref.read(dioClientProvider); final fromWallet = state.fromType; final toWallet = state.toType; if (state.spotTradingBridgeOnly || fromWallet == kWalletSpotTrading || toWallet == kWalletSpotTrading) { final direction = fromWallet == kWalletSpot ? 1 : 2; await SpotService(dio).transfer( symbol: state.selectedCoin, amount: input.toDouble(), direction: direction, ); } else { await AssetService(dio).transfer( amount: amount, from: fromWallet, to: toWallet, ); } state = state.copyWith(isSubmitting: false); await refresh(); return true; } catch (e) { state = state.copyWith( isSubmitting: false, errorMessage: extractErrorMessage(e), ); return false; } } } final transferProvider = AutoDisposeNotifierProvider( TransferNotifier.new, ); class TransferHistoryState { final List records; final bool isLoading; final bool hasMore; final int currentPage; final String? errorMessage; const TransferHistoryState({ this.records = const [], this.isLoading = false, this.hasMore = true, this.currentPage = 1, this.errorMessage, }); TransferHistoryState copyWith({ List? records, bool? isLoading, bool? hasMore, int? currentPage, String? errorMessage, }) => TransferHistoryState( records: records ?? this.records, isLoading: isLoading ?? this.isLoading, hasMore: hasMore ?? this.hasMore, currentPage: currentPage ?? this.currentPage, errorMessage: errorMessage, ); } class TransferHistoryNotifier extends AutoDisposeNotifier { static const _pageSize = 10; @override TransferHistoryState build() { Future.microtask(_loadFirst); return const TransferHistoryState(isLoading: true); } Future _loadFirst() async { state = state.copyWith(isLoading: true, errorMessage: null); try { final dio = ref.read(dioClientProvider); final records = await AssetService(dio) .getTransferList(pageNo: 1, pageSize: _pageSize); state = state.copyWith( records: records, isLoading: false, currentPage: 1, hasMore: records.length >= _pageSize, ); } catch (e) { state = state.copyWith(isLoading: false, errorMessage: e.toString()); } } Future refresh() => _loadFirst(); Future loadMore() async { if (state.isLoading || !state.hasMore) { return; } state = state.copyWith(isLoading: true); try { final nextPage = state.currentPage + 1; final dio = ref.read(dioClientProvider); final records = await AssetService(dio) .getTransferList(pageNo: nextPage, pageSize: _pageSize); state = state.copyWith( records: [...state.records, ...records], isLoading: false, currentPage: nextPage, hasMore: records.length >= _pageSize, ); } catch (e) { state = state.copyWith(isLoading: false, errorMessage: e.toString()); } } } final transferHistoryProvider = AutoDisposeNotifierProvider( TransferHistoryNotifier.new, );