532 lines
23 KiB
Swift
532 lines
23 KiB
Swift
//
|
|
// SmartBannerStore.swift
|
|
// modules
|
|
//
|
|
// Created by Lukáš Korba on 03.04.2025.
|
|
//
|
|
|
|
import Foundation
|
|
import ComposableArchitecture
|
|
import ZcashLightClientKit
|
|
|
|
import Generated
|
|
import SDKSynchronizer
|
|
import Utils
|
|
import Models
|
|
import WalletStorage
|
|
import UserPreferencesStorage
|
|
import UIComponents
|
|
import NetworkMonitor
|
|
import ZcashSDKEnvironment
|
|
import SupportDataGenerator
|
|
import MessageUI
|
|
import ShieldingProcessor
|
|
|
|
@Reducer
|
|
public struct SmartBanner {
|
|
enum Constants: Equatable {
|
|
static let easeInOutDuration = 0.85
|
|
static let remindMe2days: TimeInterval = 86_400 * 2
|
|
static let remindMe2weeks: TimeInterval = 86_400 * 14
|
|
static let remindMeMonth: TimeInterval = 86_400 * 30
|
|
}
|
|
|
|
@ObservableState
|
|
public struct State: Equatable {
|
|
public enum PriorityContent: Int {
|
|
case priority1 = 0 // disconnected
|
|
case priority2 // syncing error
|
|
case priority3 // restoring
|
|
case priority4 // syncing
|
|
case priority5 // updating balance
|
|
case priority6 // wallet backup
|
|
case priority7 // shielding
|
|
case priority8 // currency conversion
|
|
case priority9 // auto-shielding
|
|
|
|
public func next() -> PriorityContent {
|
|
PriorityContent.init(rawValue: self.rawValue - 1) ?? .priority9
|
|
}
|
|
}
|
|
|
|
public var CancelNetworkMonitorId = UUID()
|
|
public var CancelStateStreamId = UUID()
|
|
public var CancelShieldingProcessorId = UUID()
|
|
|
|
public var areFundsSpendable = false
|
|
public var delay = 1.5
|
|
public var isOpen = false
|
|
public var isShielding = false
|
|
public var isShieldingAcknowledged = false
|
|
public var isShieldingAcknowledgedAtKeychain = false
|
|
public var isSmartBannerSheetPresented = false
|
|
public var isWalletBackupAcknowledged = false
|
|
public var isWalletBackupAcknowledgedAtKeychain = false
|
|
public var lastKnownErrorMessage = ""
|
|
public var lastKnownSyncPercentage = -1.0
|
|
public var messageToBeShared: String?
|
|
public var priorityContent: PriorityContent? = nil
|
|
public var priorityContentRequested: PriorityContent? = nil
|
|
public var remindMeShieldedPhaseCounter = 0
|
|
public var remindMeWalletBackupPhaseCounter = 0
|
|
@Shared(.inMemory(.selectedWalletAccount)) public var selectedWalletAccount: WalletAccount? = nil
|
|
public var supportData: SupportData?
|
|
public var synchronizerStatusSnapshot: SyncStatusSnapshot = .snapshotFor(state: .unprepared)
|
|
public var tokenName = "ZEC"
|
|
@Shared(.inMemory(.transactions)) public var transactions: IdentifiedArrayOf<TransactionState> = []
|
|
public var transparentBalance = Zatoshi(0)
|
|
@Shared(.inMemory(.walletStatus)) public var walletStatus: WalletStatus = .none
|
|
|
|
public var feeStr: String {
|
|
Zatoshi(100_000).decimalString()
|
|
}
|
|
|
|
public var syncingPercentage: Double {
|
|
lastKnownSyncPercentage >= 0 ? lastKnownSyncPercentage * 0.999 : 0
|
|
}
|
|
|
|
public var remindMeShieldedText: String {
|
|
remindMeShieldedPhaseCounter == 0
|
|
? L10n.SmartBanner.Help.remindMePhase1
|
|
: remindMeShieldedPhaseCounter == 1
|
|
? L10n.SmartBanner.Help.remindMePhase2
|
|
: L10n.SmartBanner.Help.remindMePhase3
|
|
}
|
|
|
|
public var remindMeWalletBackupText: String {
|
|
remindMeWalletBackupPhaseCounter == 0
|
|
? L10n.SmartBanner.Help.remindMePhase1
|
|
: remindMeWalletBackupPhaseCounter == 1
|
|
? L10n.SmartBanner.Help.remindMePhase2
|
|
: L10n.SmartBanner.Help.remindMePhase3
|
|
}
|
|
|
|
public init() { }
|
|
}
|
|
|
|
public enum Action: BindableAction, Equatable {
|
|
case binding(BindingAction<SmartBanner.State>)
|
|
case closeAndCleanupBanner
|
|
case closeBanner(Bool)
|
|
case closeSheetTapped
|
|
case onAppear
|
|
case onDisappear
|
|
case evaluatePriority1
|
|
case evaluatePriority2
|
|
case evaluatePriority3
|
|
case evaluatePriority4
|
|
case evaluatePriority5
|
|
case evaluatePriority6
|
|
case evaluatePriority7
|
|
case evaluatePriority8
|
|
case evaluatePriority9
|
|
case networkMonitorChanged(Bool)
|
|
case openBanner
|
|
case openBannerRequest
|
|
case remindMeLaterTapped(State.PriorityContent)
|
|
case reportPrepared
|
|
case reportTapped
|
|
case shareFinished
|
|
case shieldingProcessorStateChanged(ShieldingProcessorClient.State)
|
|
case smartBannerContentTapped
|
|
case synchronizerStateChanged(RedactableSynchronizerState)
|
|
case transparentBalanceUpdated(Zatoshi)
|
|
case triggerPriority(State.PriorityContent)
|
|
case walletAccountChanged
|
|
|
|
// Action buttons
|
|
case autoShieldingTapped
|
|
case currencyConversionScreenRequested
|
|
case currencyConversionTapped
|
|
case shieldFundsTapped
|
|
case walletBackupTapped
|
|
}
|
|
|
|
@Dependency(\.mainQueue) var mainQueue
|
|
@Dependency(\.networkMonitor) var networkMonitor
|
|
@Dependency(\.sdkSynchronizer) var sdkSynchronizer
|
|
@Dependency(\.shieldingProcessor) var shieldingProcessor
|
|
@Dependency(\.userStoredPreferences) var userStoredPreferences
|
|
@Dependency(\.walletStorage) var walletStorage
|
|
@Dependency(\.zcashSDKEnvironment) var zcashSDKEnvironment
|
|
|
|
public init() { }
|
|
|
|
public var body: some Reducer<State, Action> {
|
|
BindingReducer()
|
|
|
|
Reduce { state, action in
|
|
switch action {
|
|
case .onAppear:
|
|
state.tokenName = zcashSDKEnvironment.tokenName
|
|
state.isWalletBackupAcknowledgedAtKeychain = walletStorage.exportWalletBackupAcknowledged()
|
|
state.isWalletBackupAcknowledged = state.isWalletBackupAcknowledgedAtKeychain
|
|
state.isShieldingAcknowledgedAtKeychain = walletStorage.exportShieldingAcknowledged()
|
|
state.isShieldingAcknowledged = state.isShieldingAcknowledgedAtKeychain
|
|
return .merge(
|
|
.publisher {
|
|
networkMonitor.networkMonitorStream()
|
|
.map(Action.networkMonitorChanged)
|
|
.receive(on: mainQueue)
|
|
}
|
|
.cancellable(id: state.CancelNetworkMonitorId, cancelInFlight: true),
|
|
.publisher {
|
|
sdkSynchronizer.stateStream()
|
|
.throttle(for: .seconds(0.2), scheduler: mainQueue, latest: true)
|
|
.map { $0.redacted }
|
|
.map(Action.synchronizerStateChanged)
|
|
}
|
|
.cancellable(id: state.CancelStateStreamId, cancelInFlight: true),
|
|
.publisher {
|
|
shieldingProcessor.observe()
|
|
.map(Action.shieldingProcessorStateChanged)
|
|
}
|
|
.cancellable(id: state.CancelShieldingProcessorId, cancelInFlight: true)
|
|
)
|
|
|
|
case .onDisappear:
|
|
return .merge(
|
|
.cancel(id: state.CancelNetworkMonitorId),
|
|
.cancel(id: state.CancelStateStreamId),
|
|
.cancel(id: state.CancelShieldingProcessorId)
|
|
)
|
|
|
|
case .binding(\.isShieldingAcknowledged):
|
|
try? walletStorage.importShieldingAcknowledged(state.isShieldingAcknowledged)
|
|
return .none
|
|
|
|
case .binding:
|
|
return .none
|
|
|
|
case .shieldingProcessorStateChanged(let shieldingProcessorState):
|
|
state.isShielding = shieldingProcessorState == .requested
|
|
if (state.isOpen || state.isSmartBannerSheetPresented) && state.priorityContent == .priority7 {
|
|
var hideEverything = false
|
|
if case .proposal = shieldingProcessorState {
|
|
hideEverything = true
|
|
} else if shieldingProcessorState == .succeeded {
|
|
hideEverything = true
|
|
}
|
|
if hideEverything {
|
|
return .merge(
|
|
.send(.closeAndCleanupBanner),
|
|
.send(.closeSheetTapped)
|
|
)
|
|
}
|
|
}
|
|
return .none
|
|
|
|
case .walletAccountChanged:
|
|
state.remindMeShieldedPhaseCounter = 0
|
|
return .run { send in
|
|
await send(.closeBanner(true), animation: .easeInOut(duration: Constants.easeInOutDuration))
|
|
try? await mainQueue.sleep(for: .seconds(1))
|
|
await send(.evaluatePriority1)
|
|
}
|
|
|
|
case .reportTapped:
|
|
return .run { send in
|
|
await send(.closeSheetTapped)
|
|
try? await mainQueue.sleep(for: .seconds(1))
|
|
await send(.reportPrepared)
|
|
}
|
|
|
|
case .reportPrepared:
|
|
var supportData = SupportDataGenerator.generate()
|
|
supportData.message =
|
|
"""
|
|
code: -2000
|
|
\(state.lastKnownErrorMessage)
|
|
|
|
\(supportData.message)
|
|
"""
|
|
if MFMailComposeViewController.canSendMail() {
|
|
state.supportData = supportData
|
|
} else {
|
|
state.messageToBeShared = supportData.message
|
|
}
|
|
return .none
|
|
|
|
case .shareFinished:
|
|
state.messageToBeShared = nil
|
|
return .none
|
|
|
|
case .networkMonitorChanged(let isConnected):
|
|
if state.priorityContent == .priority1 && isConnected {
|
|
return .run { send in
|
|
await send(.closeAndCleanupBanner)
|
|
try? await mainQueue.sleep(for: .seconds(2))
|
|
await send(.evaluatePriority2)
|
|
}
|
|
} else if state.priorityContent != .priority1 && !isConnected {
|
|
return .send(.triggerPriority(.priority1))
|
|
}
|
|
return .none
|
|
|
|
case .smartBannerContentTapped:
|
|
if state.priorityContent == .priority7 {
|
|
state.isShieldingAcknowledgedAtKeychain = walletStorage.exportShieldingAcknowledged()
|
|
if state.isShieldingAcknowledgedAtKeychain {
|
|
return .none
|
|
}
|
|
} else if state.priorityContent == .priority8 {
|
|
return .send(.currencyConversionScreenRequested)
|
|
}
|
|
state.isSmartBannerSheetPresented = true
|
|
return .none
|
|
|
|
case .closeSheetTapped:
|
|
state.isSmartBannerSheetPresented = false
|
|
return .none
|
|
|
|
case .remindMeLaterTapped(let priority):
|
|
if priority == .priority6 {
|
|
try? walletStorage.importWalletBackupAcknowledged(state.isWalletBackupAcknowledged)
|
|
state.isWalletBackupAcknowledgedAtKeychain = walletStorage.exportWalletBackupAcknowledged()
|
|
}
|
|
state.isSmartBannerSheetPresented = false
|
|
state.priorityContentRequested = nil
|
|
let now = Date().timeIntervalSince1970
|
|
// wallet backup = priority6
|
|
if priority == .priority6 {
|
|
if var walletBackupReminder = walletStorage.exportWalletBackupReminder() {
|
|
walletBackupReminder.occurence += 1
|
|
walletBackupReminder.timestamp = now
|
|
try? walletStorage.importWalletBackupReminder(walletBackupReminder)
|
|
} else {
|
|
let walletBackupReminder = ReminedMeTimestamp(timestamp: now, occurence: 1)
|
|
try? walletStorage.importWalletBackupReminder(walletBackupReminder)
|
|
}
|
|
} else if priority == .priority7 {
|
|
// shielding = priority7
|
|
if let account = state.selectedWalletAccount {
|
|
if var shieldingReminder = walletStorage.exportShieldingReminder(account.vendor.name()) {
|
|
shieldingReminder.occurence += 1
|
|
shieldingReminder.timestamp = now
|
|
try? walletStorage.importShieldingReminder(shieldingReminder, account.vendor.name())
|
|
} else {
|
|
let shieldingReminder = ReminedMeTimestamp(timestamp: now, occurence: 1)
|
|
try? walletStorage.importShieldingReminder(shieldingReminder, account.vendor.name())
|
|
}
|
|
}
|
|
}
|
|
return .run { send in
|
|
try? await mainQueue.sleep(for: .seconds(1))
|
|
await send(.closeBanner(false), animation: .easeInOut(duration: Constants.easeInOutDuration))
|
|
}
|
|
|
|
case .synchronizerStateChanged(let latestState):
|
|
let snapshot = SyncStatusSnapshot.snapshotFor(state: latestState.data.syncStatus)
|
|
if snapshot.syncStatus != state.synchronizerStatusSnapshot.syncStatus {
|
|
state.synchronizerStatusSnapshot = snapshot
|
|
|
|
if case let .syncing(syncProgress, areFundsSpendable) = snapshot.syncStatus {
|
|
state.lastKnownSyncPercentage = Double(syncProgress)
|
|
state.areFundsSpendable = areFundsSpendable
|
|
|
|
if state.priorityContent == .priority2 {
|
|
return .send(.closeAndCleanupBanner)
|
|
}
|
|
}
|
|
|
|
// error syncing check
|
|
switch snapshot.syncStatus {
|
|
case .upToDate:
|
|
if state.priorityContent == .priority3 || state.priorityContent == .priority4 {
|
|
return .send(.closeAndCleanupBanner)
|
|
}
|
|
case .error, .unprepared:
|
|
if state.lastKnownErrorMessage != snapshot.message {
|
|
state.lastKnownErrorMessage = snapshot.message
|
|
return .send(.triggerPriority(.priority2))
|
|
}
|
|
default: break
|
|
}
|
|
|
|
if state.priorityContent == .priority7 {
|
|
if let account = state.selectedWalletAccount, let accountBalance = latestState.data.accountsBalances[account.id] {
|
|
if accountBalance.unshielded.amount > 0 {
|
|
return .send(.transparentBalanceUpdated(accountBalance.unshielded))
|
|
} else {
|
|
return .merge(
|
|
.send(.closeAndCleanupBanner),
|
|
.send(.closeSheetTapped)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return .none
|
|
|
|
// disconnected
|
|
case .evaluatePriority1:
|
|
return .send(.evaluatePriority2)
|
|
|
|
// syncing error
|
|
case .evaluatePriority2:
|
|
return .send(.evaluatePriority3)
|
|
|
|
// restoring
|
|
case .evaluatePriority3:
|
|
if state.walletStatus == .restoring {
|
|
return .send(.triggerPriority(.priority3))
|
|
}
|
|
return .send(.evaluatePriority4)
|
|
|
|
// syncing
|
|
case .evaluatePriority4:
|
|
if state.walletStatus != .restoring && state.lastKnownSyncPercentage >= 0 && state.lastKnownSyncPercentage < 0.95 {
|
|
return .send(.triggerPriority(.priority4))
|
|
}
|
|
return .send(.evaluatePriority5)
|
|
|
|
// updating balance
|
|
case .evaluatePriority5:
|
|
return .send(.evaluatePriority6)
|
|
|
|
// wallet backup
|
|
case .evaluatePriority6:
|
|
guard let account = state.selectedWalletAccount, account.vendor == .zcash else {
|
|
return .send(.evaluatePriority7)
|
|
}
|
|
guard !state.transactions.isEmpty else {
|
|
return .send(.evaluatePriority7)
|
|
}
|
|
if let storedWallet = try? walletStorage.exportWallet(), !storedWallet.hasUserPassedPhraseBackupTest {
|
|
if let walletBackupReminder = walletStorage.exportWalletBackupReminder() {
|
|
state.remindMeWalletBackupPhaseCounter = walletBackupReminder.occurence
|
|
let now = Date().timeIntervalSince1970
|
|
|
|
if (state.remindMeWalletBackupPhaseCounter == 1 && walletBackupReminder.timestamp + Constants.remindMe2days < now)
|
|
|| (state.remindMeWalletBackupPhaseCounter == 2 && walletBackupReminder.timestamp + Constants.remindMe2weeks < now)
|
|
|| (state.remindMeWalletBackupPhaseCounter > 2 && walletBackupReminder.timestamp + Constants.remindMeMonth < now) {
|
|
return .send(.triggerPriority(.priority6))
|
|
}
|
|
} else {
|
|
// phase 1
|
|
return .send(.triggerPriority(.priority6))
|
|
}
|
|
}
|
|
return .send(.evaluatePriority7)
|
|
|
|
// shielding
|
|
case .evaluatePriority7:
|
|
guard let account = state.selectedWalletAccount else {
|
|
return .none
|
|
}
|
|
if let shieldedReminder = walletStorage.exportShieldingReminder(account.vendor.name()) {
|
|
state.remindMeShieldedPhaseCounter = shieldedReminder.occurence
|
|
}
|
|
return .run { [remindMeShieldedPhaseCounter = state.remindMeShieldedPhaseCounter] send in
|
|
if let accountBalance = try? await sdkSynchronizer.getAccountsBalances()[account.id],
|
|
accountBalance.unshielded >= zcashSDKEnvironment.shieldingThreshold {
|
|
await send(.transparentBalanceUpdated(accountBalance.unshielded))
|
|
|
|
if let shieldedReminder = walletStorage.exportShieldingReminder(account.vendor.name()) {
|
|
let now = Date().timeIntervalSince1970
|
|
|
|
if (remindMeShieldedPhaseCounter == 1 && shieldedReminder.timestamp + Constants.remindMe2days < now)
|
|
|| (remindMeShieldedPhaseCounter == 2 && shieldedReminder.timestamp + Constants.remindMe2weeks < now)
|
|
|| (remindMeShieldedPhaseCounter > 2 && shieldedReminder.timestamp + Constants.remindMeMonth < now) {
|
|
await send(.triggerPriority(.priority7))
|
|
}
|
|
} else {
|
|
// phase 1
|
|
await send(.triggerPriority(.priority7))
|
|
}
|
|
} else {
|
|
await send(.evaluatePriority8)
|
|
}
|
|
}
|
|
|
|
// currency conversion
|
|
case .evaluatePriority8:
|
|
if let account = state.selectedWalletAccount {
|
|
if let accountBalance = sdkSynchronizer.latestState().accountsBalances[account.id] {
|
|
let orchard = accountBalance.orchardBalance.total().amount
|
|
let sapling = accountBalance.saplingBalance.total().amount
|
|
let unshielded = accountBalance.unshielded.amount
|
|
|
|
if orchard + sapling + unshielded == 0 {
|
|
return .send(.evaluatePriority9)
|
|
}
|
|
}
|
|
}
|
|
if userStoredPreferences.exchangeRate() == nil {
|
|
return .send(.triggerPriority(.priority8))
|
|
}
|
|
return .send(.evaluatePriority9)
|
|
|
|
// auto-shielding
|
|
case .evaluatePriority9:
|
|
return .none
|
|
|
|
case .triggerPriority(let priority):
|
|
state.priorityContentRequested = priority
|
|
return .send(.openBannerRequest)
|
|
|
|
case .transparentBalanceUpdated(let balance):
|
|
state.transparentBalance = balance
|
|
return .none
|
|
|
|
case .openBannerRequest:
|
|
guard let priorityContentRequested = state.priorityContentRequested else {
|
|
return .none
|
|
}
|
|
if let priorityContent = state.priorityContent, priorityContentRequested.rawValue >= priorityContent.rawValue {
|
|
return .none
|
|
}
|
|
if state.isOpen {
|
|
return .run { send in
|
|
await send(.closeBanner(false), animation: .easeInOut(duration: Constants.easeInOutDuration))
|
|
}
|
|
}
|
|
state.priorityContent = priorityContentRequested
|
|
return .run { [delay = state.delay] send in
|
|
try? await mainQueue.sleep(for: .seconds(delay))
|
|
await send(.openBanner, animation: .easeInOut(duration: Constants.easeInOutDuration))
|
|
}
|
|
|
|
case .closeBanner(let clean):
|
|
state.isOpen = false
|
|
if clean {
|
|
state.priorityContentRequested = nil
|
|
state.priorityContent = nil
|
|
}
|
|
return .send(.openBannerRequest)
|
|
|
|
case .closeAndCleanupBanner:
|
|
return .run { send in
|
|
await send(.closeBanner(true), animation: .easeInOut(duration: Constants.easeInOutDuration))
|
|
}
|
|
|
|
case .openBanner:
|
|
state.delay = 1.0
|
|
state.isOpen = true
|
|
return .none
|
|
|
|
// MARK: - Actions
|
|
|
|
case .autoShieldingTapped:
|
|
return .none
|
|
|
|
case .currencyConversionScreenRequested:
|
|
return .none
|
|
|
|
case .currencyConversionTapped:
|
|
return .send(.smartBannerContentTapped)
|
|
|
|
case .shieldFundsTapped:
|
|
state.isSmartBannerSheetPresented = false
|
|
shieldingProcessor.shieldFunds()
|
|
return .send(.closeAndCleanupBanner)
|
|
|
|
case .walletBackupTapped:
|
|
state.isSmartBannerSheetPresented = false
|
|
return .none
|
|
}
|
|
}
|
|
}
|
|
}
|