Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

IOS-8666 [Balance caching] #4532

Merged
merged 8 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions Tangem/App/Models/Visa/VisaWalletModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Foundation
import Combine
import BlockchainSdk
import TangemVisa
import TangemFoundation

class VisaWalletModel {
@Injected(\.quotesRepository) private var quotesRepository: TokenQuotesRepository
Expand Down Expand Up @@ -266,26 +267,26 @@ extension VisaWalletModel: VisaWalletMainHeaderSubtitleDataSource {
}

extension VisaWalletModel: MainHeaderBalanceProvider {
var balanceProvider: AnyPublisher<LoadingValue<AttributedString>, Never> {
var balanceProvider: AnyPublisher<LoadableTokenBalanceView.State, Never> {
stateSubject.combineLatest(balancesSubject)
.map { [weak self] state, balances -> LoadingValue<AttributedString> in
.map { [weak self] state, balances in
guard let self else {
return .loading
return .loading()
}

switch state {
case .notInitialized, .loading:
return .loading
return .loading()
case .failedToInitialize(let error):
return .failedToLoad(error: error)
return .failed(cached: .string(BalanceFormatter.defaultEmptyBalanceString))
case .idle:
if let balances, let tokenItem {
let balanceFormatter = BalanceFormatter()
let formattedBalance = balanceFormatter.formatCryptoBalance(balances.available, currencyCode: tokenItem.currencySymbol)
let formattedForMain = balanceFormatter.formatAttributedTotalBalance(fiatBalance: formattedBalance)
return .loaded(formattedForMain)
return .loaded(text: .attributed(formattedForMain))
} else {
return .loading
return .loading()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import Combine
/// Just simple available to use (e.g. send) balance
struct AvailableTokenBalanceProvider {
private let walletModel: WalletModel
private let tokenBalancesRepository: TokenBalancesRepository
private let balanceFormatter = BalanceFormatter()

init(walletModel: WalletModel) {
init(walletModel: WalletModel, tokenBalancesRepository: TokenBalancesRepository) {
self.walletModel = walletModel
self.tokenBalancesRepository = tokenBalancesRepository
}
}

Expand Down Expand Up @@ -46,6 +48,20 @@ extension AvailableTokenBalanceProvider: TokenBalanceProvider {
// MARK: - Private

private extension AvailableTokenBalanceProvider {
func storeBalance(balance: Decimal) {
tokenBalancesRepository.store(
balance: .init(balance: balance, date: .now),
for: walletModel,
type: .available
)
}

func cachedBalance() -> TokenBalanceType.Cached? {
tokenBalancesRepository.balance(walletModel: walletModel, type: .available).map {
.init(balance: $0.balance, date: $0.date)
}
}

func mapToTokenBalance(state: WalletModel.State) -> TokenBalanceType {
// The `binance` always has zero balance
if case .binance = walletModel.tokenItem.blockchain {
Expand All @@ -55,16 +71,15 @@ private extension AvailableTokenBalanceProvider {
switch state {
case .created:
return .empty(.noData)
case .noDerivation:
return .empty(.noDerivation)
case .loading:
return .loading(nil)
return .loading(cachedBalance())
case .loaded(let balance):
storeBalance(balance: balance)
return .loaded(balance)
case .noAccount(let message, _):
return .empty(.noAccount(message: message))
case .failed:
return .failure(nil)
return .failure(cachedBalance())
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import TangemStaking

struct StakingTokenBalanceProvider {
private let walletModel: WalletModel
private let tokenBalancesRepository: TokenBalancesRepository
private let balanceFormatter = BalanceFormatter()

init(walletModel: WalletModel) {
init(walletModel: WalletModel, tokenBalancesRepository: TokenBalancesRepository) {
self.walletModel = walletModel
self.tokenBalancesRepository = tokenBalancesRepository
}
}

Expand Down Expand Up @@ -46,18 +48,34 @@ extension StakingTokenBalanceProvider: TokenBalanceProvider {
// MARK: - Private

extension StakingTokenBalanceProvider {
func storeBalance(balance: Decimal) {
tokenBalancesRepository.store(
balance: .init(balance: balance, date: .now),
for: walletModel,
type: .staked
)
}

func cachedBalance() -> TokenBalanceType.Cached? {
tokenBalancesRepository.balance(walletModel: walletModel, type: .staked).map {
.init(balance: $0.balance, date: $0.date)
}
}

func mapToTokenBalance(state: StakingManagerState) -> TokenBalanceType {
switch state {
case .loading:
return .loading(.none)
return .loading(cachedBalance())
case .notEnabled, .temporaryUnavailable:
return .empty(.noData)
case .loadingError:
return .failure(.none)
return .failure(cachedBalance())
case .availableToStake:
storeBalance(balance: .zero)
return .loaded(.zero)
case .staked(let balances):
let balance = balances.balances.blocked().sum()
storeBalance(balance: balance)
return .loaded(balance)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,32 +38,37 @@ extension CommonExpressDestinationService: ExpressDestinationService {

return isNotSource && isAvailable && isNotCustom && hasPair
}
.map { walletModel -> (walletModel: WalletModel, fiatBalance: Decimal?) in
(walletModel: walletModel, fiatBalance: walletModel.fiatAvailableBalanceProvider.balanceType.value)
}

AppLog.shared.debug("[Express] \(self) has searchableWalletModels: \(searchableWalletModels.map { ($0.expressCurrency, $0.fiatBalance) })")
log("Has searchableWalletModels: \(searchableWalletModels.map(\.walletModel.expressCurrency))")

if let lastSwappedWallet = searchableWalletModels.first(where: { isLastTransactionWith(walletModel: $0) }) {
AppLog.shared.debug("[Express] \(self) selected lastSwappedWallet: \(lastSwappedWallet.expressCurrency)")
return lastSwappedWallet
if let lastSwappedWallet = searchableWalletModels.first(where: { isLastTransactionWith(walletModel: $0.walletModel) }) {
log("Select lastSwappedWallet: \(lastSwappedWallet.walletModel.expressCurrency)")
return lastSwappedWallet.walletModel
}

let walletModelsWithPositiveBalance = searchableWalletModels.filter { ($0.fiatValue ?? 0) > 0 }
let walletModelsWithPositiveBalance = searchableWalletModels.filter { ($0.fiatBalance ?? 0) > 0 }

// If all wallets without balance
if walletModelsWithPositiveBalance.isEmpty, let first = searchableWalletModels.first {
AppLog.shared.debug("[Express] \(self) has a zero wallets with positive balance then selected: \(first.expressCurrency)")
return first
log("Has a zero wallets with positive balance then selected: \(first.walletModel.expressCurrency)")
return first.walletModel
}

// If user has wallets with balance then sort they
let sortedWallets = walletModelsWithPositiveBalance.sorted(by: { ($0.fiatValue ?? 0) > ($1.fiatValue ?? 0) })
let sortedWallets = walletModelsWithPositiveBalance.sorted(by: {
($0.fiatBalance ?? 0) > ($1.fiatBalance ?? 0)
})

// Start searching destination with available providers
if let maxBalanceWallet = sortedWallets.first {
AppLog.shared.debug("[Express] \(self) selected maxBalanceWallet: \(maxBalanceWallet.expressCurrency)")
return maxBalanceWallet
log("Select maxBalanceWallet: \(maxBalanceWallet.walletModel.expressCurrency)")
return maxBalanceWallet.walletModel
}

AppLog.shared.debug("[Express] \(self) couldn't find acceptable wallet")
log("Couldn't find acceptable wallet")
throw ExpressDestinationServiceError.destinationNotFound
}
}
Expand All @@ -77,6 +82,10 @@ private extension CommonExpressDestinationService {

return walletModel.expressCurrency == lastCurrency
}

func log(_ message: String) {
AppLog.shared.debug("[Express] \(self) \(message)")
}
}

extension CommonExpressDestinationService: CustomStringConvertible {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,27 +41,26 @@ final class SingleTokenNotificationManager {
private func bind() {
bag = []

Publishers.CombineLatest(
walletModel.walletDidChangePublisher,
walletModel.stakingManagerStatePublisher
)
.receive(on: DispatchQueue.main)
.sink { [weak self] walletState, stakingState in
self?.notificationsUpdateTask?.cancel()

switch walletState {
case .failed:
self?.setupNetworkUnreachable()
case .noAccount(let message, _):
self?.setupNoAccountNotification(with: message)
case .loading, .created:
break
case .loaded, .noDerivation:
guard stakingState != .loading else { return } // fixes issue with staking notification animated re-appear
self?.setupLoadedStateNotifications()
walletModel.totalTokenBalanceProvider
.balanceTypePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
self?.notificationsUpdateTask?.cancel()

switch state {
case .failure(.none):
self?.setupNetworkUnreachable()
case .failure(.some(let cached)):
self?.setupNetworkNotUpdated(lastUpdatedDate: cached.date)
case .empty(.noAccount(let message)):
self?.setupNoAccountNotification(with: message)
case .loaded:
self?.setupLoadedStateNotifications()
case .loading, .empty:
break
}
}
}
.store(in: &bag)
.store(in: &bag)
}

private func setupLoadedStateNotifications() {
Expand Down Expand Up @@ -178,6 +177,14 @@ final class SingleTokenNotificationManager {
}
}

private func setupNetworkNotUpdated(lastUpdatedDate: Date) {
notificationInputsSubject.send([
NotificationsFactory().buildNotificationInput(
for: TokenNotificationEvent.networkNotUpdated(lastUpdatedDate: lastUpdatedDate)
),
])
}

private func setupNoAccountNotification(with message: String) {
// Skip displaying the BEP2 account creation top-up notification
// since it will be deprecated shortly due to the network shutdown
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,47 +11,38 @@ import Combine

final class MultiWalletNotificationManager {
private let analyticsService = NotificationsAnalyticsService()
private let walletModelsManager: WalletModelsManager
private let totalBalanceProvider: TotalBalanceProviding

private let notificationInputsSubject: CurrentValueSubject<[NotificationViewInput], Never> = .init([])
private var updateSubscription: AnyCancellable?

init(walletModelsManager: WalletModelsManager, contextDataProvider: AnalyticsContextDataProvider?) {
self.walletModelsManager = walletModelsManager
init(totalBalanceProvider: TotalBalanceProviding, contextDataProvider: AnalyticsContextDataProvider?) {
self.totalBalanceProvider = totalBalanceProvider

analyticsService.setup(with: self, contextDataProvider: contextDataProvider)
bind()
}

private func bind() {
updateSubscription = walletModelsManager.walletModelsPublisher
.removeDuplicates()
.flatMap { walletModels in
let coinsOnlyModels = walletModels.filter { !$0.tokenItem.isToken }
return Publishers.MergeMany(coinsOnlyModels.map { $0.walletDidChangePublisher })
.map { _ in coinsOnlyModels }
.filter { walletModels in
walletModels.allConforms { !$0.state.isLoading }
}
updateSubscription = totalBalanceProvider
.totalBalancePublisher
.withWeakCaptureOf(self)
.sink { manager, state in
manager.setup(state: state)
}
.sink { [weak self] walletModels in
let unreachableNetworks = walletModels.filter {
if case .binance = $0.blockchainNetwork.blockchain {
return false
}

return $0.state.isBlockchainUnreachable
}

guard !unreachableNetworks.isEmpty else {
self?.show(event: .none)
return
}
}

self?.show(event: .someNetworksUnreachable(
currencySymbols: unreachableNetworks.map(\.tokenItem.currencySymbol)
))
}
private func setup(state: TotalBalanceState) {
switch state {
case .empty, .loading:
break
case .failed(cached: .some, _):
show(event: .someTokenBalancesNotUpdated)
case .failed(cached: .none, let unreachableNetworks):
show(event: .someNetworksUnreachable(currencySymbols: unreachableNetworks.map(\.currencySymbol)))
case .loaded:
show(event: .none)
}
}

private func show(event: MultiWalletNotificationEvent?) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ final class CommonRateAppController {
private var isBalanceLoadedPublisher: AnyPublisher<Bool, Never> {
userWalletModel
.totalBalancePublisher
.map { $0.value != nil }
.map { $0.isLoaded }
.removeDuplicates()
.eraseToAnyPublisher()
}
Expand Down Expand Up @@ -58,7 +58,7 @@ final class CommonRateAppController {
private func bind() {
userWalletModel
.totalBalancePublisher
.compactMap { $0.value }
.filter { $0.isLoaded }
.withWeakCaptureOf(self)
.sink { controller, _ in
let walletModels = controller.userWalletModel.walletModelsManager.walletModels
Expand Down
Loading
Loading