secant-ios-wallet/modules/Sources/Features/WalletBalances/WalletBalancesStore.swift

228 lines
9.1 KiB
Swift

//
// WalletBalancesStore.swift
// Zashi
//
// Created by Lukáš Korba on 04-02-2024
//
import Foundation
import ComposableArchitecture
import ExchangeRate
import Models
import SDKSynchronizer
import Utils
import ZcashLightClientKit
import ZcashSDKEnvironment
import UserPreferencesStorage
@Reducer
public struct WalletBalances {
private let CancelStateId = UUID()
private let CancelRateId = UUID()
@ObservableState
public struct State: Equatable {
public var autoShieldingThreshold: Zatoshi = .zero
@Shared(.inMemory(.exchangeRate)) public var currencyConversion: CurrencyConversion? = nil
public var fiatCurrencyResult: FiatCurrencyResult?
public var isAvailableBalanceTappable = true
public var isExchangeRateFeatureOn = false
public var isExchangeRateRefreshEnabled = false
public var isExchangeRateStale = false
public var migratingDatabase = false
@Shared(.inMemory(.selectedWalletAccount)) public var selectedWalletAccount: WalletAccount? = nil
public var shieldedBalance: Zatoshi
public var shieldedWithPendingBalance: Zatoshi
public var spendability: Spendability = .everything
public var totalBalance: Zatoshi
public var transparentBalance: Zatoshi
public var isExchangeRateUSDInFlight: Bool {
fiatCurrencyResult?.state == .fetching
}
public var isProcessingZeroAvailableBalance: Bool {
if shieldedBalance.amount == 0 && transparentBalance.amount > autoShieldingThreshold.amount {
return false
}
return totalBalance.amount != shieldedBalance.amount && shieldedBalance.amount == 0
}
public var currencyValue: String {
currencyConversion?.convert(totalBalance) ?? ""
}
public init(
fiatCurrencyResult: FiatCurrencyResult? = nil,
isAvailableBalanceTappable: Bool = true,
isExchangeRateFeatureOn: Bool = false,
isExchangeRateRefreshEnabled: Bool = false,
isExchangeRateStale: Bool = false,
migratingDatabase: Bool = false,
shieldedBalance: Zatoshi = .zero,
shieldedWithPendingBalance: Zatoshi = .zero,
totalBalance: Zatoshi = .zero,
transparentBalance: Zatoshi = .zero
) {
self.fiatCurrencyResult = fiatCurrencyResult
self.isAvailableBalanceTappable = isAvailableBalanceTappable
self.isExchangeRateFeatureOn = isExchangeRateFeatureOn
self.isExchangeRateRefreshEnabled = isExchangeRateRefreshEnabled
self.isExchangeRateStale = isExchangeRateStale
self.migratingDatabase = migratingDatabase
self.shieldedBalance = shieldedBalance
self.shieldedWithPendingBalance = shieldedWithPendingBalance
self.totalBalance = totalBalance
self.transparentBalance = transparentBalance
}
}
public enum Action: Equatable {
case availableBalanceTapped
case balanceUpdated(AccountBalance?)
case debugMenuStartup
case exchangeRateRefreshTapped
case exchangeRateEvent(ExchangeRateClient.EchangeRateEvent)
case onAppear
case onDisappear
case synchronizerStateChanged(RedactableSynchronizerState)
case updateBalances
}
@Dependency(\.exchangeRate) var exchangeRate
@Dependency(\.mainQueue) var mainQueue
@Dependency(\.sdkSynchronizer) var sdkSynchronizer
@Dependency(\.userStoredPreferences) var userStoredPreferences
@Dependency(\.zcashSDKEnvironment) var zcashSDKEnvironment
public init() { }
public var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .onAppear:
state.autoShieldingThreshold = zcashSDKEnvironment.shieldingThreshold
if let exchangeRate = userStoredPreferences.exchangeRate(), exchangeRate.automatic {
state.isExchangeRateFeatureOn = true
} else {
state.isExchangeRateFeatureOn = false
}
return .merge(
.send(.updateBalances),
.publisher {
sdkSynchronizer.stateStream()
.throttle(for: .seconds(0.2), scheduler: mainQueue, latest: true)
.map { $0.redacted }
.map(Action.synchronizerStateChanged)
}
.cancellable(id: CancelStateId, cancelInFlight: true),
.publisher {
exchangeRate.exchangeRateEventStream()
.map(Action.exchangeRateEvent)
.receive(on: mainQueue)
}
.cancellable(id: CancelRateId, cancelInFlight: true)
)
case .onDisappear:
return .merge(
.cancel(id: CancelStateId),
.cancel(id: CancelRateId)
)
case .availableBalanceTapped:
return .none
case .exchangeRateRefreshTapped:
if !state.isExchangeRateStale {
exchangeRate.refreshExchangeRateUSD()
}
return .none
case .exchangeRateEvent(let result):
switch result {
case .value(let rate):
guard let rate else {
return .none
}
state.fiatCurrencyResult = rate
state.$currencyConversion.withLock {
$0 = CurrencyConversion(.usd, ratio: rate.rate.doubleValue, timestamp: rate.date.timeIntervalSince1970)
}
state.isExchangeRateRefreshEnabled = false
state.isExchangeRateStale = false
case .refreshEnable(let rate):
guard let rate else {
return .none
}
state.fiatCurrencyResult = rate
state.$currencyConversion.withLock {
$0 = CurrencyConversion(.usd, ratio: rate.rate.doubleValue, timestamp: rate.date.timeIntervalSince1970)
}
state.isExchangeRateRefreshEnabled = true
state.isExchangeRateStale = false
case .stale:
state.$currencyConversion.withLock {
$0 = nil
}
state.isExchangeRateStale = true
break
}
return .none
case .updateBalances:
guard let account = state.selectedWalletAccount else {
return .none
}
return .run { send in
if let accountBalance = try? await sdkSynchronizer.getAccountsBalances()[account.id] {
await send(.balanceUpdated(accountBalance))
} else if let accountBalance = sdkSynchronizer.latestState().accountsBalances[account.id] {
await send(.balanceUpdated(accountBalance))
}
}
case .balanceUpdated(let accountBalance):
state.shieldedBalance = (accountBalance?.saplingBalance.spendableValue ?? .zero) + (accountBalance?.orchardBalance.spendableValue ?? .zero)
state.shieldedWithPendingBalance = (accountBalance?.saplingBalance.total() ?? .zero) + (accountBalance?.orchardBalance.total() ?? .zero)
state.transparentBalance = accountBalance?.unshielded ?? .zero
state.totalBalance = state.shieldedWithPendingBalance + state.transparentBalance
let everythingCondition = state.shieldedBalance.amount > 0 && ((state.shieldedBalance == state.totalBalance)
|| (state.transparentBalance < zcashSDKEnvironment.shieldingThreshold && state.shieldedBalance == state.totalBalance - state.transparentBalance))
// spendability
if state.isProcessingZeroAvailableBalance {
state.spendability = .nothing
} else if everythingCondition {
state.spendability = .everything
} else {
state.spendability = .something
}
return .none
case .debugMenuStartup:
return .none
case .synchronizerStateChanged(let latestState):
let snapshot = SyncStatusSnapshot.snapshotFor(state: latestState.data.syncStatus)
if snapshot.syncStatus != .unprepared {
state.migratingDatabase = false
}
guard let account = state.selectedWalletAccount else {
return .none
}
return .send(.balanceUpdated(latestState.data.accountsBalances[account.id]))
}
}
}
}