secant-ios-wallet/modules/Sources/Features/SendForm/SendFormStore.swift

512 lines
19 KiB
Swift

//
// SendFormStore.swift
// Zashi
//
// Created by Lukáš Korba on 04/25/2022.
//
import SwiftUI
import ComposableArchitecture
import ZcashLightClientKit
import AudioServices
import Utils
import Scan
import SDKSynchronizer
import ZcashSDKEnvironment
import UIComponents
import Models
import Generated
import BalanceFormatter
import WalletBalances
import NumberFormatter
import UserPreferencesStorage
import AddressBookClient
import ZcashPaymentURI
import BalanceBreakdown
@Reducer
public struct SendForm {
public enum Confirmation {
case requestPayment
case send
}
@ObservableState
public struct State {
public var cancelId = UUID()
public var addMemoState: Bool
public var address: RedactableString = .empty
@Shared(.inMemory(.addressBookContacts)) public var addressBookContacts: AddressBookContacts = .empty
@Presents public var alert: AlertState<Action>?
public var balancesBinding = false
public var balancesState = Balances.State.initial
@Shared(.inMemory(.exchangeRate)) public var currencyConversion: CurrencyConversion? = nil
public var currencyText: RedactableString = .empty
public var isAddressBookHintVisible = false
public var isCurrencyConversionEnabled = false
public var isLatestInputFiat = false
public var isNotAddressInAddressBook = false
public var isPopToRootBack = false
public var isValidAddress = false
public var isValidTransparentAddress = false
public var isValidTexAddress = false
public var memoState: MessageEditor.State
public var proposal: Proposal?
@Shared(.inMemory(.selectedWalletAccount)) public var selectedWalletAccount: WalletAccount? = nil
public var shieldedBalance: Zatoshi
public var walletBalancesState: WalletBalances.State
public var requestsAddressFocus = false
@Shared(.inMemory(.zashiWalletAccount)) public var zashiWalletAccount: WalletAccount? = nil
public var zecAmountText: RedactableString = .empty
public var sheetHeight: CGFloat = 0.0
public var amount: Zatoshi {
get {
if !_XCTIsTesting {
@Dependency(\.numberFormatter) var numberFormatter
var amount = Zatoshi.zero
guard let number = numberFormatter.number(zecAmountText.data) else {
return amount
}
amount = Zatoshi(NSDecimalNumber(
decimal: number.decimalValue * Decimal(Zatoshi.Constants.oneZecInZatoshi)
).roundedZec.int64Value)
return amount
} else {
return .zero
}
}
set {
zecAmountText = newValue.amount == 0
? .empty
: newValue.decimalString().redacted
}
}
public var currencySymbol: String {
currencyConversion?.iso4217.symbol ?? ""
}
public var feeFormat: String {
"(\(ZatoshiStringRepresentation.feeFormat))"
}
public var feeRequired: Zatoshi {
proposal?.totalFeeRequired() ?? Zatoshi(0)
}
public var message: String {
memoState.text
}
public var isValidAmount: Bool {
if !_XCTIsTesting {
@Dependency(\.numberFormatter) var numberFormatter
return numberFormatter.number(zecAmountText.data) != nil
} else {
return true
}
}
public var isInvalidAddressFormat: Bool {
!address.data.isEmpty
&& !isValidAddress
}
public var isInvalidAmountFormat: Bool {
if !_XCTIsTesting {
@Dependency(\.numberFormatter) var numberFormatter
return !zecAmountText.data.isEmpty
&& !isValidAmount
|| (numberFormatter.number(currencyText.data) == nil && !currencyText.data.isEmpty)
} else {
return true
}
}
public var isValidForm: Bool {
isValidAddress
&& !isInsufficientFunds
&& memoState.isValid
&& isValidAmount
}
public var isInsufficientFunds: Bool {
guard isValidAmount else { return false }
return amount.amount > shieldedBalance.amount
}
public var isMemoInputEnabled: Bool {
!isValidTransparentAddress && !isValidTexAddress
}
public var spendableBalanceString: String {
shieldedBalance.decimalString(formatter: NumberFormatter.zashiBalanceFormatter)
}
public var invalidAddressErrorText: String? {
isInvalidAddressFormat
? L10n.Send.Error.invalidAddress
: nil
}
public var invalidZecAmountErrorText: String? {
zecAmountText.data.isEmpty
? nil
: isInvalidAmountFormat
? L10n.Send.Error.invalidAmount
: isInsufficientFunds
? L10n.Send.Error.insufficientFunds
: nil
}
public var invalidCurrencyAmountErrorText: String? {
currencyText.data.isEmpty
? nil
: isInvalidAmountFormat
? L10n.Send.Error.invalidAmount
: isInsufficientFunds
? L10n.Send.Error.insufficientFunds
: nil
}
public init(
addMemoState: Bool,
memoState: MessageEditor.State,
shieldedBalance: Zatoshi = .zero,
walletBalancesState: WalletBalances.State
) {
self.addMemoState = addMemoState
self.memoState = memoState
self.shieldedBalance = shieldedBalance
self.walletBalancesState = walletBalancesState
}
}
public enum Action: BindableAction {
case addNewContactTapped(RedactableString)
case addressBookTapped
case addressUpdated(RedactableString)
case alert(PresentationAction<Action>)
case balances(Balances.Action)
case balancesBindingUpdated(Bool)
case binding(BindingAction<SendForm.State>)
case confirmationRequired(Confirmation)
case dismissRequired
case getProposal(Confirmation)
case currencyUpdated(RedactableString)
case dismissAddressBookHint
case exchangeRateSetupChanged
case memo(MessageEditor.Action)
case onAppear
case onDisapear
case proposal(Proposal)
case requestsAddressFocusResolved
case requestZec(ParserResult)
case resetForm
case reviewTapped
case scanTapped
case sendFailed(ZcashError, Confirmation)
case syncAmounts(Bool)
case validateAddress
case walletBalances(WalletBalances.Action)
case zecAmountUpdated(RedactableString)
}
@Dependency(\.addressBook) var addressBook
@Dependency(\.audioServices) var audioServices
@Dependency(\.derivationTool) var derivationTool
@Dependency(\.numberFormatter) var numberFormatter
@Dependency(\.sdkSynchronizer) var sdkSynchronizer
@Dependency(\.userStoredPreferences) var userStoredPreferences
@Dependency(\.zcashSDKEnvironment) var zcashSDKEnvironment
public init() { }
public var body: some Reducer<State, Action> {
BindingReducer()
Scope(state: \.memoState, action: \.memo) {
MessageEditor()
}
Scope(state: \.walletBalancesState, action: \.walletBalances) {
WalletBalances()
}
Scope(state: \.balancesState, action: \.balances) {
Balances()
}
Reduce { state, action in
switch action {
case .onAppear:
state.memoState.charLimit = zcashSDKEnvironment.memoCharLimit
return .send(.exchangeRateSetupChanged)
case .onDisapear:
return .cancel(id: state.cancelId)
case .alert(.presented(let action)):
return .send(action)
case .alert(.dismiss):
state.alert = nil
return .none
case .alert:
return .none
case .binding:
return .none
case .balances(.sheetHeightUpdated(let value)):
state.sheetHeight = value
return .none
case .addressBookTapped:
return .none
case .addNewContactTapped:
state.requestsAddressFocus = true
return .none
case .requestsAddressFocusResolved:
state.requestsAddressFocus = false
return .none
case .exchangeRateSetupChanged:
if let automatic = userStoredPreferences.exchangeRate()?.automatic, automatic {
state.isCurrencyConversionEnabled = true
} else {
state.isCurrencyConversionEnabled = false
}
return .none
case let .proposal(proposal):
state.proposal = proposal
return .none
case .walletBalances(.exchangeRateEvent(let result)):
switch result {
case .value(let rate), .refreshEnable(let rate):
if let rate {
state.$currencyConversion.withLock { $0 = CurrencyConversion(.usd, ratio: rate.rate.doubleValue, timestamp: rate.date.timeIntervalSince1970) }
return .send(.syncAmounts(true))
}
case .stale:
state.$currencyConversion.withLock { $0 = nil }
return .none
}
return .none
case .reviewTapped:
return .send(.getProposal(.send))
case .getProposal(let confirmationType):
guard let account = state.selectedWalletAccount else {
return .none
}
state.amount = state.isLatestInputFiat ? state.amount.roundToAvoidDustSpend() : state.amount
return .run { [state, confirmationType] send in
do {
let recipient = try Recipient(state.address.data, network: zcashSDKEnvironment.network.networkType)
let memo: Memo?
if state.isValidTransparentAddress || state.isValidTexAddress {
memo = nil
} else if let memoText = state.addMemoState ? state.memoState.text : nil {
memo = memoText.isEmpty ? nil : try Memo(string: memoText)
} else {
memo = nil
}
let proposal = try await sdkSynchronizer.proposeTransfer(account.id, recipient, state.amount, memo)
await send(.proposal(proposal))
await send(.confirmationRequired(confirmationType))
} catch {
await send(.sendFailed(error.toZcashError(), confirmationType))
}
}
case let .sendFailed(error, confirmationType):
if confirmationType == .send {
state.alert = AlertState.sendFailure(error)
}
return .none
case .confirmationRequired:
return .none
case .resetForm:
state.memoState.text = ""
state.address = .empty
state.zecAmountText = .empty
state.currencyText = .empty
state.isValidAddress = false
state.isValidTransparentAddress = false
state.isValidTexAddress = false
state.isNotAddressInAddressBook = false
return .none
case .syncAmounts(let zecToCurrency):
guard let currencyConversion = state.currencyConversion else {
return .none
}
if zecToCurrency {
if state.zecAmountText.data.isEmpty || !state.isValidAmount {
state.currencyText = .empty
} else {
let value: Double = currencyConversion.convert(Zatoshi(state.amount.amount))
state.currencyText = Decimal(value).formatted(.number.precision(.fractionLength(2))).redacted
}
} else {
if let number = numberFormatter.number(state.currencyText.data) {
if let value = Double(exactly: number) {
let value2 = currencyConversion.convert(value)
state.zecAmountText = value2.decimalString().redacted
}
} else if state.currencyText.data.isEmpty {
state.zecAmountText = .empty
}
}
return .none
case .memo:
return .none
case .requestZec(let requestPayment):
if case .legacy(let address) = requestPayment {
audioServices.systemSoundVibrate()
return .send(.addressUpdated(address.value.redacted))
} else if case .request(let paymentRequest) = requestPayment {
if let payment = paymentRequest.payments.first {
if let memoBytes = payment.memo, let memo = try? Memo(bytes: [UInt8](memoBytes.memoData)) {
state.memoState.text = memo.toString() ?? ""
}
let numberLocale = numberFormatter.convertUSToLocale(payment.amount.toString()) ?? ""
audioServices.systemSoundVibrate()
return .concatenate(
.send(.zecAmountUpdated(numberLocale.redacted)),
.send(.addressUpdated(payment.recipientAddress.value.redacted)),
.send(.getProposal(.requestPayment))
)
}
}
return .none
case .walletBalances(.balanceUpdated):
state.shieldedBalance = state.walletBalancesState.shieldedBalance
return .none
case .walletBalances(.availableBalanceTapped):
state.balancesBinding = true
return .none
case .balancesBindingUpdated(let newState):
state.balancesBinding = newState
return .none
case .balances(.dismissTapped):
state.balancesBinding = false
return .none
case .balances(.shieldFundsTapped):
state.balancesBinding = false
return .none
case .balances(.everythingSpendable):
if state.balancesBinding {
state.balancesBinding = false
}
return .none
case .balances:
return .none
case .walletBalances:
return .none
case .addressUpdated(let newValue):
let network = zcashSDKEnvironment.network.networkType
state.address = newValue
state.isValidAddress = derivationTool.isZcashAddress(state.address.data, network)
state.isValidTransparentAddress = derivationTool.isTransparentAddress(state.address.data, network)
state.isValidTexAddress = derivationTool.isTexAddress(state.address.data, network)
if !state.isMemoInputEnabled {
state.memoState.text = ""
}
state.isNotAddressInAddressBook = state.isValidAddress
var isNotAddressInAddressBook = state.isNotAddressInAddressBook
if state.isValidAddress {
for contact in state.addressBookContacts.contacts {
if contact.id == state.address.data {
state.isNotAddressInAddressBook = false
isNotAddressInAddressBook = false
break
}
}
}
if isNotAddressInAddressBook {
state.isAddressBookHintVisible = true
return .run { send in
try await Task.sleep(nanoseconds: 3_000_000_000)
await send(.dismissAddressBookHint)
}
.cancellable(id: state.cancelId)
} else {
state.isAddressBookHintVisible = false
return .cancel(id: state.cancelId)
}
case .dismissAddressBookHint:
state.isAddressBookHintVisible = false
return .none
case .currencyUpdated(let newValue):
state.currencyText = newValue
state.isLatestInputFiat = true
return .send(.syncAmounts(false))
case .validateAddress:
let network = zcashSDKEnvironment.network.networkType
state.isValidAddress = derivationTool.isZcashAddress(state.address.data, network)
state.isValidTransparentAddress = derivationTool.isTransparentAddress(state.address.data, network)
state.isValidTexAddress = derivationTool.isTexAddress(state.address.data, network)
return .none
case .zecAmountUpdated(let newValue):
state.zecAmountText = newValue
state.isLatestInputFiat = false
return .send(.syncAmounts(true))
case .dismissRequired:
return .none
case .scanTapped:
return .none
}
}
}
}
// MARK: Alerts
extension AlertState where Action == SendForm.Action {
public static func sendFailure(_ error: ZcashError) -> AlertState {
AlertState {
TextState(L10n.Send.Alert.Failure.title)
} message: {
TextState(L10n.Send.Alert.Failure.message(error.detailedMessage))
}
}
}