diff --git a/RadixWallet.xcodeproj/project.pbxproj b/RadixWallet.xcodeproj/project.pbxproj index 51fa5aad26..1d13268d9b 100644 --- a/RadixWallet.xcodeproj/project.pbxproj +++ b/RadixWallet.xcodeproj/project.pbxproj @@ -1165,6 +1165,8 @@ E7B7A0FD2BBBFB6100EEE900 /* HeaderListViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7B7A0FC2BBBFB6100EEE900 /* HeaderListViewContainer.swift */; }; E7B7A1052BC904E800EEE900 /* NewConnectionApproval+Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7B7A1042BC904E800EEE900 /* NewConnectionApproval+Reducer.swift */; }; E7B7A1072BC904EE00EEE900 /* NewConnectionApproval+View.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7B7A1062BC904EE00EEE900 /* NewConnectionApproval+View.swift */; }; + E7D8D8C22C35349F00032417 /* AssetsView+Selection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7D8D8C12C35349F00032417 /* AssetsView+Selection.swift */; }; + E7D8D8C42C35373800032417 /* AssetsView+Update.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7D8D8C32C35373800032417 /* AssetsView+Update.swift */; }; E7FAE65D2BF0F2EC00620273 /* NewConnectionFeatureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7FAE65B2BF0F2EC00620273 /* NewConnectionFeatureTests.swift */; }; /* End PBXBuildFile section */ @@ -2304,6 +2306,8 @@ E7B7A0FC2BBBFB6100EEE900 /* HeaderListViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderListViewContainer.swift; sourceTree = ""; }; E7B7A1042BC904E800EEE900 /* NewConnectionApproval+Reducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NewConnectionApproval+Reducer.swift"; sourceTree = ""; }; E7B7A1062BC904EE00EEE900 /* NewConnectionApproval+View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NewConnectionApproval+View.swift"; sourceTree = ""; }; + E7D8D8C12C35349F00032417 /* AssetsView+Selection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AssetsView+Selection.swift"; sourceTree = ""; }; + E7D8D8C32C35373800032417 /* AssetsView+Update.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AssetsView+Update.swift"; sourceTree = ""; }; E7FAE65B2BF0F2EC00620273 /* NewConnectionFeatureTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewConnectionFeatureTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -3812,6 +3816,8 @@ children = ( 48CFBE342ADC10D800E77A5C /* AssetsView+Reducer.swift */, 48CFBE672ADC10D800E77A5C /* AssetsView+View.swift */, + E7D8D8C12C35349F00032417 /* AssetsView+Selection.swift */, + E7D8D8C32C35373800032417 /* AssetsView+Update.swift */, 48CFBE352ADC10D800E77A5C /* Components */, ); path = AssetsFeature; @@ -7216,6 +7222,7 @@ 48CFC33B2ADC10D900E77A5C /* DappInteractionLoading.swift in Sources */, 48CFC34A2ADC10D900E77A5C /* PersonaDataPermission+View.swift in Sources */, 48CFC53D2ADC10DA00E77A5C /* MetadataI32ArrayValue.swift in Sources */, + E7D8D8C42C35373800032417 /* AssetsView+Update.swift in Sources */, A462B5A42B8384FB00C26D20 /* CoreAPI_PlaintextMessageContent.swift in Sources */, 48CFC4312ADC10DA00E77A5C /* PublicKey+Extensions.swift in Sources */, 48CFC2CE2ADC10D900E77A5C /* ReceivingAccount+Reducer.swift in Sources */, @@ -7358,6 +7365,7 @@ 48CFC56E2ADC10DA00E77A5C /* TransactionStatus.swift in Sources */, 48CFC5392ADC10DA00E77A5C /* NonFungibleResourcesCollectionItemVaultAggregated.swift in Sources */, 48CFC36D2ADC10D900E77A5C /* FungibleAssetList+Row+Reducer.swift in Sources */, + E7D8D8C22C35349F00032417 /* AssetsView+Selection.swift in Sources */, A462B5B92B90C57400C26D20 /* ResourceBalanceButton.swift in Sources */, 48CFC4EC2ADC10DA00E77A5C /* NonFungibleResourcesCollectionItemVaultAggregatedVault.swift in Sources */, A47809082BDBDB4C006B68C0 /* RadixDateFormatter.swift in Sources */, diff --git a/RadixWallet/Features/AccountDetailsFeature/Coordinator/AccountDetails+Reducer.swift b/RadixWallet/Features/AccountDetailsFeature/Coordinator/AccountDetails+Reducer.swift index 68e8e3a842..44d3016376 100644 --- a/RadixWallet/Features/AccountDetailsFeature/Coordinator/AccountDetails+Reducer.swift +++ b/RadixWallet/Features/AccountDetailsFeature/Coordinator/AccountDetails+Reducer.swift @@ -3,6 +3,10 @@ import SwiftUI // MARK: - AccountDetails public struct AccountDetails: Sendable, FeatureReducer { + private enum CancellableId: Hashable { + case fetchAccountPortfolio + } + public struct State: Sendable, Hashable, AccountWithInfoHolder { public var accountWithInfo: AccountWithInfo var assets: AssetsView.State @@ -29,6 +33,7 @@ public struct AccountDetails: Sendable, FeatureReducer { public enum ViewAction: Sendable, Equatable { case task + case onDisappear case backButtonTapped case preferencesButtonTapped case transferButtonTapped @@ -116,6 +121,9 @@ public struct AccountDetails: Sendable, FeatureReducer { @Dependency(\.appPreferencesClient) var appPreferencesClient @Dependency(\.dappInteractionClient) var dappInteractionClient @Dependency(\.securityCenterClient) var securityCenterClient + @Dependency(\.accountPortfoliosClient) var accountPortfoliosClient + + private let accountPortfolioRefreshIntervalInSeconds = 60 public init() {} @@ -141,6 +149,10 @@ public struct AccountDetails: Sendable, FeatureReducer { } } .merge(with: securityProblemsEffect()) + .merge(with: scheduleFetchAccountPortfolioTimer(state)) + + case .onDisappear: + return .cancel(id: CancellableId.fetchAccountPortfolio) case .backButtonTapped: return .send(.delegate(.dismiss)) @@ -277,4 +289,14 @@ public struct AccountDetails: Sendable, FeatureReducer { } } } + + private func scheduleFetchAccountPortfolioTimer(_ state: State) -> Effect { + .run { [address = state.account.address] _ in + for await _ in clock.timer(interval: .seconds(accountPortfolioRefreshIntervalInSeconds)) { + guard !Task.isCancelled else { return } + _ = try? await accountPortfoliosClient.fetchAccountPortfolio(address, true) + } + } + .cancellable(id: CancellableId.fetchAccountPortfolio, cancelInFlight: true) + } } diff --git a/RadixWallet/Features/AccountDetailsFeature/Coordinator/AccountDetails+View.swift b/RadixWallet/Features/AccountDetailsFeature/Coordinator/AccountDetails+View.swift index 8b73bc9bcb..a91e772432 100644 --- a/RadixWallet/Features/AccountDetailsFeature/Coordinator/AccountDetails+View.swift +++ b/RadixWallet/Features/AccountDetailsFeature/Coordinator/AccountDetails+View.swift @@ -45,7 +45,10 @@ extension AccountDetails { .background(viewStore.appearanceID.gradient) .navigationBarBackButtonHidden() .task { - viewStore.send(.task) + await viewStore.send(.task).finish() + } + .onDisappear { + viewStore.send(.onDisappear) } .toolbar { ToolbarItem(placement: .principal) { diff --git a/RadixWallet/Features/AssetsFeature/AssetsView+Reducer.swift b/RadixWallet/Features/AssetsFeature/AssetsView+Reducer.swift index 912b9de1e2..1e8da4c439 100644 --- a/RadixWallet/Features/AssetsFeature/AssetsView+Reducer.swift +++ b/RadixWallet/Features/AssetsFeature/AssetsView+Reducer.swift @@ -67,7 +67,7 @@ public struct AssetsView: Sendable, FeatureReducer { } public enum ViewAction: Sendable, Equatable { - case task + case onFirstTask case pullToRefreshStarted case didSelectList(State.AssetKind) case chooseButtonTapped(State.Mode.SelectedAssets) @@ -100,6 +100,7 @@ public struct AssetsView: Sendable, FeatureReducer { @Dependency(\.accountPortfoliosClient) var accountPortfoliosClient @Dependency(\.onLedgerEntitiesClient) var onLedgerEntitiesClient + @Dependency(\.errorQueue) var errorQueue public init() {} @@ -121,7 +122,7 @@ public struct AssetsView: Sendable, FeatureReducer { public func reduce(into state: inout State, viewAction: ViewAction) -> Effect { switch viewAction { - case .task: + case .onFirstTask: state.isLoadingResources = true state.accountPortfolio = .loading return .run { [state] send in @@ -132,16 +133,16 @@ public struct AssetsView: Sendable, FeatureReducer { } catch: { error, _ in loggerGlobal.error("AssetsView portfolioForAccount failed: \(error)") } + .merge(with: fetchAccountPortfolio(state)) + case let .didSelectList(kind): state.activeAssetKind = kind return .none + case .pullToRefreshStarted: state.isRefreshing = true - return .run { [address = state.account.address] _ in - _ = try await accountPortfoliosClient.fetchAccountPortfolio(address, true) - } catch: { error, _ in - loggerGlobal.error("AssetsView fetch failed: \(error)") - } + return fetchAccountPortfolio(state) + case let .chooseButtonTapped(items): return .send(.delegate(.handleSelectedAssets(items))) } @@ -181,328 +182,13 @@ public struct AssetsView: Sendable, FeatureReducer { .none } } -} - -extension AssetsView { - private func updateFromPortfolio( - state: inout State, - from portfolio: AccountPortfoliosClient.AccountPortfolio - ) { - let mode = state.mode - let xrd = portfolio.account.fungibleResources.xrdResource.map { token in - FungibleAssetList.Section.Row.State( - xrdToken: token, - isSelected: mode.xrdRowSelected - ) - } - let nonXrd = portfolio.account.fungibleResources.nonXrdResources - .map { token in - FungibleAssetList.Section.Row.State( - nonXRDToken: token, - isSelected: mode.nonXrdRowSelected(token.resourceAddress) - ) - } - .asIdentified() - - let nfts = portfolio.account.nonFungibleResources.map { resource in - NonFungibleAssetList.Row.State( - accountAddress: portfolio.account.address, - resource: resource, - disabled: mode.selectedAssets?.disabledNFTs ?? [], - selectedAssets: mode.nftRowSelectedAssets(resource.resourceAddress) - ) - } - - let fungibleTokenList: FungibleAssetList.State? = { - var sections: IdentifiedArrayOf = [] - if let xrd { - sections.append(.init(id: .xrd, rows: [xrd])) - } - - if !nonXrd.isEmpty { - sections.append(.init(id: .nonXrd, rows: nonXrd)) - } - - guard !sections.isEmpty else { - return nil - } - - return .init(sections: sections) - }() - - state.accountPortfolio.refresh(from: .success(portfolio)) - - let poolUnits = portfolio.account.poolUnitResources.poolUnits - let poolUnitList: PoolUnitsList.State? = poolUnits.isEmpty ? nil : .init( - poolUnits: poolUnits.map { poolUnit in - PoolUnitsList.State.PoolUnitState( - poolUnit: poolUnit, - resourceDetails: state.accountPortfolio.poolUnitDetails.flatten().flatMap { - $0.first { poolUnit.resourcePoolAddress == $0.address }.map(Loadable.success) ?? .loading - }, - isSelected: mode.nonXrdRowSelected(poolUnit.resource.resourceAddress) - ) - }.asIdentified() - ) - - let stakes = portfolio.account.poolUnitResources.radixNetworkStakes - - let stakeUnitList: StakeUnitList.State? = stakes.isEmpty ? nil : .init( - account: portfolio.account, - selectedLiquidStakeUnits: mode.selectedAssets.map { assets in - let stakeUnitResources = stakes.map(\.stakeUnitResource) - return assets - .fungibleResources - .nonXrdResources - .filter(stakeUnitResources.contains) - .asIdentified() - }, - selectedStakeClaimTokens: - mode.isSelection ? - stakes - .compactMap(\.stakeClaimResource) - .reduce(into: StakeUnitList.SelectedStakeClaimTokens()) { dict, resource in - if let selectedtokens = mode.nftRowSelectedAssets(resource.resourceAddress)?.elements.asIdentified() { - dict[resource] = selectedtokens - } - } : nil, - stakeUnitDetails: state.accountPortfolio.stakeUnitDetails.flatten() - ) - - state.totalFiatWorth.refresh(from: portfolio.totalFiatWorth) - state.resources = .init( - fungibleTokenList: fungibleTokenList, - nonFungibleTokenList: !nfts.isEmpty ? .init(rows: nfts.asIdentified()) : nil, - stakeUnitList: stakeUnitList, - poolUnitsList: poolUnitList - ) - } -} - -extension AccountPortfoliosClient.AccountPortfolio { - mutating func refresh(from portfolio: AccountPortfoliosClient.AccountPortfolio) { - self.account = portfolio.account - self.isCurrencyAmountVisible = portfolio.isCurrencyAmountVisible - self.fiatCurrency = portfolio.fiatCurrency - self.stakeUnitDetails.refresh(from: portfolio.stakeUnitDetails) - self.poolUnitDetails.refresh(from: portfolio.poolUnitDetails) - } -} - -extension Loadable where Value == AccountPortfoliosClient.AccountPortfolio { - mutating func refresh(from portfolio: Loadable) { - self.refresh(from: portfolio, valueChangeMap: { old, new in - var old = old - old.refresh(from: new) - return old - }) - } -} - -extension AssetsView.State { - /// Computed property of currently selected assets - public var selectedAssets: Mode.SelectedAssets? { - guard case .selection = mode else { return nil } - - let selectedLiquidStakeUnits = resources.stakeUnitList?.selectedLiquidStakeUnits ?? [] - - let selectedPoolUnitTokens = resources.poolUnitsList?.poolUnits - .map(SelectedResourceProvider.init) - .compactMap(\.selectedResource) ?? [] - - let selectedXRDResource = resources.fungibleTokenList?.sections[id: .xrd]? - .rows - .first - .map(SelectedResourceProvider.init) - .flatMap(\.selectedResource) - - let selectedNonXrdResources = resources.fungibleTokenList?.sections[id: .nonXrd]?.rows - .map(SelectedResourceProvider.init) - .compactMap(\.selectedResource) ?? [] - - let selectedNonFungibleResources = resources.nonFungibleTokenList?.rows.compactMap(NonFungibleTokensPerResourceProvider.init) ?? [] - let selectedStakeClaims = resources.stakeUnitList?.selectedStakeClaimTokens?.map { resource, tokens in - NonFungibleTokensPerResourceProvider(selectedAssets: .init(tokens), resource: resource) - } ?? [] - - let selectedFungibleResources = OnLedgerEntity.OwnedFungibleResources( - xrdResource: selectedXRDResource, - nonXrdResources: selectedNonXrdResources + selectedLiquidStakeUnits + selectedPoolUnitTokens - ) - - let selectedNonFungibleTokensPerResource = - (selectedNonFungibleResources + selectedStakeClaims) - .compactMap(\.nonFungibleTokensPerResource) - - guard - selectedFungibleResources.xrdResource != nil - || !selectedFungibleResources.nonXrdResources.isEmpty - || !selectedNonFungibleTokensPerResource.isEmpty - else { - return nil - } - - return .init( - fungibleResources: selectedFungibleResources, - nonFungibleResources: IdentifiedArrayOf(uniqueElements: selectedNonFungibleTokensPerResource), - disabledNFTs: mode.selectedAssets?.disabledNFTs ?? [] - ) - } - - public var chooseButtonTitle: String { - guard let selectedAssets else { - return L10n.AssetTransfer.AddAssets.buttonAssetsNone - } - - if selectedAssets.assetsCount == 1 { - return L10n.AssetTransfer.AddAssets.buttonAssetsOne - } - - return L10n.AssetTransfer.AddAssets.buttonAssets(selectedAssets.assetsCount) - } -} - -// MARK: - AssetsView.State.Mode -extension AssetsView.State { - public enum Mode: Hashable, Sendable { - public struct SelectedAssets: Hashable, Sendable { - public struct NonFungibleTokensPerResource: Hashable, Sendable, Identifiable { - public var id: ResourceAddress { - resourceAddress - } - - public let resourceAddress: ResourceAddress - public let resourceImage: URL? - public let resourceName: String? - public var tokens: IdentifiedArrayOf - - public init( - resourceAddress: ResourceAddress, - resourceImage: URL?, - resourceName: String?, - tokens: IdentifiedArrayOf - ) { - self.resourceAddress = resourceAddress - self.resourceImage = resourceImage - self.resourceName = resourceName - self.tokens = tokens - } - } - - public var fungibleResources: OnLedgerEntity.OwnedFungibleResources - public var nonFungibleResources: IdentifiedArrayOf - public var disabledNFTs: Set - - public init( - fungibleResources: OnLedgerEntity.OwnedFungibleResources = .init(), - nonFungibleResources: IdentifiedArrayOf = [], - disabledNFTs: Set - ) { - self.fungibleResources = fungibleResources - self.nonFungibleResources = nonFungibleResources - self.disabledNFTs = disabledNFTs - } - - public var assetsCount: Int { - fungibleResources.nonXrdResources.count + - nonFungibleResources.map(\.tokens.count).reduce(0, +) + - (fungibleResources.xrdResource != nil ? 1 : 0) - } - } - - case normal - case selection(SelectedAssets) - - public var selectedAssets: SelectedAssets? { - switch self { - case .normal: - nil - case let .selection(assets): - assets - } - } - - public var isSelection: Bool { - if case .selection = self { - return true - } - return false - } - - var xrdRowSelected: Bool? { - selectedAssets.map { $0.fungibleResources.xrdResource != nil } - } - - func nonXrdRowSelected(_ resource: ResourceAddress) -> Bool? { - selectedAssets?.fungibleResources.nonXrdResources.contains { $0.resourceAddress == resource } - } - - func nftRowSelectedAssets(_ resource: ResourceAddress) -> OrderedSet? { - selectedAssets.map { OrderedSet($0.nonFungibleResources[id: resource]?.tokens.elements ?? []) } - } - } -} - -// MARK: - SelectedResourceProvider -private struct SelectedResourceProvider { - let isSelected: Bool? - let resource: Resource - - var selectedResource: Resource? { - isSelected.flatMap { $0 ? resource : nil } - } -} - -extension SelectedResourceProvider { - init(with row: FungibleAssetList.Section.Row.State) { - self.init( - isSelected: row.isSelected, - resource: row.token - ) - } - - init(with poolUnit: PoolUnitsList.State.PoolUnitState) { - self.init( - isSelected: poolUnit.isSelected, - resource: poolUnit.poolUnit.resource - ) - } -} - -// MARK: - NonFungibleTokensPerResourceProvider -private struct NonFungibleTokensPerResourceProvider { - let selectedAssets: OrderedSet? - let resource: OnLedgerEntity.OwnedNonFungibleResource? - - var nonFungibleTokensPerResource: AssetsView.State.Mode.SelectedAssets.NonFungibleTokensPerResource? { - selectedAssets.flatMap { selectedAssets -> AssetsView.State.Mode.SelectedAssets.NonFungibleTokensPerResource? in - guard - let resource, - !selectedAssets.isEmpty - else { - return nil - } - -// let selected = selectedAssets.filter { -// resource.nonFungibleIds.contains($0.id) -// } - // resource.tokens.filter { token in selectedStakeClaimAssets.contains(token.id) } - return .init( - resourceAddress: resource.resourceAddress, - resourceImage: resource.metadata.iconURL, - resourceName: resource.metadata.title, - tokens: .init(uncheckedUniqueElements: selectedAssets) - ) + public func fetchAccountPortfolio(_ state: State) -> Effect { + .run { [address = state.account.address] _ in + _ = try await accountPortfoliosClient.fetchAccountPortfolio(address, true) + } catch: { error, _ in + loggerGlobal.error("AssetsView fetch failed: \(error)") + errorQueue.schedule(error) } } } - -extension NonFungibleTokensPerResourceProvider { - init(with row: NonFungibleAssetList.Row.State) { - self.init( - selectedAssets: row.selectedAssets, - resource: row.resource - ) - } -} diff --git a/RadixWallet/Features/AssetsFeature/AssetsView+Selection.swift b/RadixWallet/Features/AssetsFeature/AssetsView+Selection.swift new file mode 100644 index 0000000000..65e6ac095f --- /dev/null +++ b/RadixWallet/Features/AssetsFeature/AssetsView+Selection.swift @@ -0,0 +1,206 @@ +extension AssetsView.State { + /// Computed property of currently selected assets + public var selectedAssets: Mode.SelectedAssets? { + guard case .selection = mode else { return nil } + + let selectedLiquidStakeUnits = resources.stakeUnitList?.selectedLiquidStakeUnits ?? [] + + let selectedPoolUnitTokens = resources.poolUnitsList?.poolUnits + .map(SelectedResourceProvider.init) + .compactMap(\.selectedResource) ?? [] + + let selectedXRDResource = resources.fungibleTokenList?.sections[id: .xrd]? + .rows + .first + .map(SelectedResourceProvider.init) + .flatMap(\.selectedResource) + + let selectedNonXrdResources = resources.fungibleTokenList?.sections[id: .nonXrd]?.rows + .map(SelectedResourceProvider.init) + .compactMap(\.selectedResource) ?? [] + + let selectedNonFungibleResources = resources.nonFungibleTokenList?.rows.compactMap(NonFungibleTokensPerResourceProvider.init) ?? [] + let selectedStakeClaims = resources.stakeUnitList?.selectedStakeClaimTokens?.map { resource, tokens in + NonFungibleTokensPerResourceProvider(selectedAssets: .init(tokens), resource: resource) + } ?? [] + + let selectedFungibleResources = OnLedgerEntity.OwnedFungibleResources( + xrdResource: selectedXRDResource, + nonXrdResources: selectedNonXrdResources + selectedLiquidStakeUnits + selectedPoolUnitTokens + ) + + let selectedNonFungibleTokensPerResource = + (selectedNonFungibleResources + selectedStakeClaims) + .compactMap(\.nonFungibleTokensPerResource) + + guard + selectedFungibleResources.xrdResource != nil + || !selectedFungibleResources.nonXrdResources.isEmpty + || !selectedNonFungibleTokensPerResource.isEmpty + else { + return nil + } + + return .init( + fungibleResources: selectedFungibleResources, + nonFungibleResources: IdentifiedArrayOf(uniqueElements: selectedNonFungibleTokensPerResource), + disabledNFTs: mode.selectedAssets?.disabledNFTs ?? [] + ) + } + + public var chooseButtonTitle: String { + guard let selectedAssets else { + return L10n.AssetTransfer.AddAssets.buttonAssetsNone + } + + if selectedAssets.assetsCount == 1 { + return L10n.AssetTransfer.AddAssets.buttonAssetsOne + } + + return L10n.AssetTransfer.AddAssets.buttonAssets(selectedAssets.assetsCount) + } +} + +// MARK: - AssetsView.State.Mode +import ComposableArchitecture +import SwiftUI + +// MARK: - AssetsView.State.Mode +extension AssetsView.State { + public enum Mode: Hashable, Sendable { + public struct SelectedAssets: Hashable, Sendable { + public struct NonFungibleTokensPerResource: Hashable, Sendable, Identifiable { + public var id: ResourceAddress { + resourceAddress + } + + public let resourceAddress: ResourceAddress + public let resourceImage: URL? + public let resourceName: String? + public var tokens: IdentifiedArrayOf + + public init( + resourceAddress: ResourceAddress, + resourceImage: URL?, + resourceName: String?, + tokens: IdentifiedArrayOf + ) { + self.resourceAddress = resourceAddress + self.resourceImage = resourceImage + self.resourceName = resourceName + self.tokens = tokens + } + } + + public var fungibleResources: OnLedgerEntity.OwnedFungibleResources + public var nonFungibleResources: IdentifiedArrayOf + public var disabledNFTs: Set + + public init( + fungibleResources: OnLedgerEntity.OwnedFungibleResources = .init(), + nonFungibleResources: IdentifiedArrayOf = [], + disabledNFTs: Set + ) { + self.fungibleResources = fungibleResources + self.nonFungibleResources = nonFungibleResources + self.disabledNFTs = disabledNFTs + } + + public var assetsCount: Int { + fungibleResources.nonXrdResources.count + + nonFungibleResources.map(\.tokens.count).reduce(0, +) + + (fungibleResources.xrdResource != nil ? 1 : 0) + } + } + + case normal + case selection(SelectedAssets) + + public var selectedAssets: SelectedAssets? { + switch self { + case .normal: + nil + case let .selection(assets): + assets + } + } + + public var isSelection: Bool { + if case .selection = self { + return true + } + return false + } + + var xrdRowSelected: Bool? { + selectedAssets.map { $0.fungibleResources.xrdResource != nil } + } + + func nonXrdRowSelected(_ resource: ResourceAddress) -> Bool? { + selectedAssets?.fungibleResources.nonXrdResources.contains { $0.resourceAddress == resource } + } + + func nftRowSelectedAssets(_ resource: ResourceAddress) -> OrderedSet? { + selectedAssets.map { OrderedSet($0.nonFungibleResources[id: resource]?.tokens.elements ?? []) } + } + } +} + +// MARK: - SelectedResourceProvider +private struct SelectedResourceProvider { + let isSelected: Bool? + let resource: Resource + + var selectedResource: Resource? { + isSelected.flatMap { $0 ? resource : nil } + } +} + +extension SelectedResourceProvider { + init(with row: FungibleAssetList.Section.Row.State) { + self.init( + isSelected: row.isSelected, + resource: row.token + ) + } + + init(with poolUnit: PoolUnitsList.State.PoolUnitState) { + self.init( + isSelected: poolUnit.isSelected, + resource: poolUnit.poolUnit.resource + ) + } +} + +// MARK: - NonFungibleTokensPerResourceProvider +private struct NonFungibleTokensPerResourceProvider { + let selectedAssets: OrderedSet? + let resource: OnLedgerEntity.OwnedNonFungibleResource? + + var nonFungibleTokensPerResource: AssetsView.State.Mode.SelectedAssets.NonFungibleTokensPerResource? { + selectedAssets.flatMap { selectedAssets -> AssetsView.State.Mode.SelectedAssets.NonFungibleTokensPerResource? in + guard + let resource, + !selectedAssets.isEmpty + else { + return nil + } + + return .init( + resourceAddress: resource.resourceAddress, + resourceImage: resource.metadata.iconURL, + resourceName: resource.metadata.title, + tokens: .init(uncheckedUniqueElements: selectedAssets) + ) + } + } +} + +extension NonFungibleTokensPerResourceProvider { + init(with row: NonFungibleAssetList.Row.State) { + self.init( + selectedAssets: row.selectedAssets, + resource: row.resource + ) + } +} diff --git a/RadixWallet/Features/AssetsFeature/AssetsView+Update.swift b/RadixWallet/Features/AssetsFeature/AssetsView+Update.swift new file mode 100644 index 0000000000..d99528fdba --- /dev/null +++ b/RadixWallet/Features/AssetsFeature/AssetsView+Update.swift @@ -0,0 +1,178 @@ +import ComposableArchitecture +import SwiftUI + +extension AssetsView { + func updateFromPortfolio( + state: inout State, + from portfolio: AccountPortfoliosClient.AccountPortfolio + ) { + let mode = state.mode + let xrd = portfolio.account.fungibleResources.xrdResource.map { token in + let updatedRow = state.resources.fungibleTokenList?.updatedRow(token: token, for: .xrd) + + return updatedRow ?? FungibleAssetList.Section.Row.State( + xrdToken: token, + isSelected: mode.xrdRowSelected + ) + } + let nonXrd = portfolio.account.fungibleResources.nonXrdResources.map { token in + let updatedRow = state.resources.fungibleTokenList?.updatedRow(token: token, for: .nonXrd) + + return updatedRow ?? FungibleAssetList.Section.Row.State( + nonXRDToken: token, + isSelected: mode.nonXrdRowSelected(token.resourceAddress) + ) + } + .asIdentified() + + let nfts = portfolio.account.nonFungibleResources.map { resource in + let updatedRow = state.resources.nonFungibleTokenList?.updatedRow(resource: resource) + + return updatedRow ?? NonFungibleAssetList.Row.State( + accountAddress: portfolio.account.address, + resource: resource, + disabled: mode.selectedAssets?.disabledNFTs ?? [], + selectedAssets: mode.nftRowSelectedAssets(resource.resourceAddress) + ) + } + + let fungibleTokenList: FungibleAssetList.State? = { + var sections: IdentifiedArrayOf = [] + if let xrd { + sections.append(.init(id: .xrd, rows: [xrd])) + } + + if !nonXrd.isEmpty { + sections.append(.init(id: .nonXrd, rows: nonXrd)) + } + + guard !sections.isEmpty else { + return nil + } + + return .init(sections: sections) + }() + + state.accountPortfolio.refresh(from: .success(portfolio)) + + let poolUnits = portfolio.account.poolUnitResources.poolUnits + let poolUnitList: PoolUnitsList.State? = poolUnits.isEmpty ? nil : .init( + poolUnits: poolUnits.map { poolUnit in + let resourceDetails = state.accountPortfolio.poolUnitDetails.flatten().flatMap { + $0.first { poolUnit.resourcePoolAddress == $0.address }.map(Loadable.success) ?? .loading + } + let updatedPoolUnit = state.resources.poolUnitsList?.updatedPoolUnit(poolUnit: poolUnit, resourceDetails: resourceDetails) + + return updatedPoolUnit ?? PoolUnitsList.State.PoolUnitState( + poolUnit: poolUnit, + resourceDetails: resourceDetails, + isSelected: mode.nonXrdRowSelected(poolUnit.resource.resourceAddress) + ) + }.asIdentified() + ) + + let stakes = portfolio.account.poolUnitResources.radixNetworkStakes + + let stakeUnitList: StakeUnitList.State? = { + guard !stakes.isEmpty else { return nil } + + let stakeUnitDetails = state.accountPortfolio.stakeUnitDetails.flatten() + if let stakeUnitList = state.resources.stakeUnitList { + return .init( + account: stakeUnitList.account, + selectedLiquidStakeUnits: stakeUnitList.selectedLiquidStakeUnits, + selectedStakeClaimTokens: stakeUnitList.selectedStakeClaimTokens, + stakeUnitDetails: stakeUnitDetails + ) + } else { + return .init( + account: portfolio.account, + selectedLiquidStakeUnits: mode.selectedAssets.map { assets in + let stakeUnitResources = stakes.map(\.stakeUnitResource) + return assets + .fungibleResources + .nonXrdResources + .filter(stakeUnitResources.contains) + .asIdentified() + }, + selectedStakeClaimTokens: + mode.isSelection ? + stakes + .compactMap(\.stakeClaimResource) + .reduce(into: StakeUnitList.SelectedStakeClaimTokens()) { dict, resource in + if let selectedtokens = mode.nftRowSelectedAssets(resource.resourceAddress)?.elements.asIdentified() { + dict[resource] = selectedtokens + } + } : nil, + stakeUnitDetails: stakeUnitDetails + ) + } + }() + + state.totalFiatWorth.refresh(from: portfolio.totalFiatWorth) + state.resources = .init( + fungibleTokenList: fungibleTokenList, + nonFungibleTokenList: !nfts.isEmpty ? .init(rows: nfts.asIdentified()) : nil, + stakeUnitList: stakeUnitList, + poolUnitsList: poolUnitList + ) + } +} + +extension AccountPortfoliosClient.AccountPortfolio { + mutating func refresh(from portfolio: AccountPortfoliosClient.AccountPortfolio) { + self.account = portfolio.account + self.isCurrencyAmountVisible = portfolio.isCurrencyAmountVisible + self.fiatCurrency = portfolio.fiatCurrency + self.stakeUnitDetails.refresh(from: portfolio.stakeUnitDetails) + self.poolUnitDetails.refresh(from: portfolio.poolUnitDetails) + } +} + +extension Loadable where Value == AccountPortfoliosClient.AccountPortfolio { + mutating func refresh(from portfolio: Loadable) { + self.refresh(from: portfolio, valueChangeMap: { old, new in + var old = old + old.refresh(from: new) + return old + }) + } +} + +extension FungibleAssetList.State { + public mutating func updatedRow( + token: OnLedgerEntity.OwnedFungibleResource, + for sectionID: FungibleAssetList.Section.State.ID + ) -> FungibleAssetList.Section.Row.State? { + guard + let section = sections.first(where: { $0.id == sectionID }), + var row = section.rows.first(where: { $0.id == token.resourceAddress }) + else { return nil } + + row.token = token + + return row + } +} + +extension NonFungibleAssetList.State { + public mutating func updatedRow(resource: OnLedgerEntity.OwnedNonFungibleResource) -> NonFungibleAssetList.Row.State? { + guard var row = rows.first(where: { $0.id == resource.resourceAddress }) else { return nil } + row.resource = resource + return row + } +} + +extension PoolUnitsList.State { + public mutating func updatedPoolUnit( + poolUnit: OnLedgerEntity.OnLedgerAccount.PoolUnit, + resourceDetails: Loadable + ) -> PoolUnitsList.State.PoolUnitState? { + guard var poolUnitState = poolUnits.first(where: { $0.id == poolUnit.resourcePoolAddress }) else { return nil } + + poolUnitState.poolUnit = poolUnit + poolUnitState.resourceDetails = resourceDetails + + return poolUnitState + } +} diff --git a/RadixWallet/Features/AssetsFeature/AssetsView+View.swift b/RadixWallet/Features/AssetsFeature/AssetsView+View.swift index 2fd963e0f6..f3a1624651 100644 --- a/RadixWallet/Features/AssetsFeature/AssetsView+View.swift +++ b/RadixWallet/Features/AssetsFeature/AssetsView+View.swift @@ -94,7 +94,7 @@ extension AssetsView { .ignoresSafeArea(edges: .bottom) } .onFirstTask { @MainActor in - viewStore.send(.task) + viewStore.send(.onFirstTask) } } } diff --git a/RadixWallet/Features/AssetsFeature/Components/NonFungibleAssetList/Components/Row/NonFungbileAssetRow+Reducer.swift b/RadixWallet/Features/AssetsFeature/Components/NonFungibleAssetList/Components/Row/NonFungbileAssetRow+Reducer.swift index b36644d877..5fd1dcd24f 100644 --- a/RadixWallet/Features/AssetsFeature/Components/NonFungibleAssetList/Components/Row/NonFungbileAssetRow+Reducer.swift +++ b/RadixWallet/Features/AssetsFeature/Components/NonFungibleAssetList/Components/Row/NonFungbileAssetRow+Reducer.swift @@ -9,7 +9,7 @@ extension NonFungibleAssetList { public var id: ResourceAddress { resource.resourceAddress } public typealias AssetID = OnLedgerEntity.NonFungibleToken.ID - public let resource: OnLedgerEntity.OwnedNonFungibleResource + public var resource: OnLedgerEntity.OwnedNonFungibleResource public let accountAddress: AccountAddress /// The loaded pages of tokens diff --git a/RadixWallet/Features/AssetsFeature/Components/PoolUnitsList/PoolUnitsList.swift b/RadixWallet/Features/AssetsFeature/Components/PoolUnitsList/PoolUnitsList.swift index 773a1f867b..e7652ed688 100644 --- a/RadixWallet/Features/AssetsFeature/Components/PoolUnitsList/PoolUnitsList.swift +++ b/RadixWallet/Features/AssetsFeature/Components/PoolUnitsList/PoolUnitsList.swift @@ -8,7 +8,7 @@ public struct PoolUnitsList: Sendable, FeatureReducer { public struct PoolUnitState: Sendable, Hashable, Identifiable { public var id: PoolAddress { poolUnit.resourcePoolAddress } - public let poolUnit: OnLedgerEntity.OnLedgerAccount.PoolUnit + public var poolUnit: OnLedgerEntity.OnLedgerAccount.PoolUnit public var resourceDetails: Loadable = .idle public var isSelected: Bool? = nil } diff --git a/RadixWallet/Features/AssetsFeature/Components/StakeUnitList/StakeUnitList.swift b/RadixWallet/Features/AssetsFeature/Components/StakeUnitList/StakeUnitList.swift index c4cfab436c..173c24bf15 100644 --- a/RadixWallet/Features/AssetsFeature/Components/StakeUnitList/StakeUnitList.swift +++ b/RadixWallet/Features/AssetsFeature/Components/StakeUnitList/StakeUnitList.swift @@ -93,7 +93,7 @@ public struct StakeUnitList: Sendable, FeatureReducer { ) self.stakedValidators = validatorStakes - case let .failure(error): + case .failure: self.stakeSummary = .init( staked: .loading, unstaking: .loading, diff --git a/RadixWallet/Features/HomeFeature/Coordinator/Home+View.swift b/RadixWallet/Features/HomeFeature/Coordinator/Home+View.swift index 9dff733956..417f6f115c 100644 --- a/RadixWallet/Features/HomeFeature/Coordinator/Home+View.swift +++ b/RadixWallet/Features/HomeFeature/Coordinator/Home+View.swift @@ -97,6 +97,9 @@ extension Home { .onFirstAppear { store.send(.view(.onFirstAppear)) } + .onDisappear { + store.send(.view(.onDisappear)) + } } private struct HeaderView: SwiftUI.View { diff --git a/RadixWallet/Features/HomeFeature/Coordinator/Home.swift b/RadixWallet/Features/HomeFeature/Coordinator/Home.swift index a4fee67120..aff2cae33e 100644 --- a/RadixWallet/Features/HomeFeature/Coordinator/Home.swift +++ b/RadixWallet/Features/HomeFeature/Coordinator/Home.swift @@ -6,6 +6,10 @@ import SwiftUI public struct Home: Sendable, FeatureReducer { public static let radixBannerURL = URL(string: "https://wallet.radixdlt.com/?wallet=downloaded")! + private enum CancellableId: Hashable { + case fetchAccountPortfolios + } + public struct State: Sendable, Hashable { // MARK: - Components public var accountRows: IdentifiedArrayOf = [] @@ -46,6 +50,7 @@ public struct Home: Sendable, FeatureReducer { public enum ViewAction: Sendable, Equatable { case onFirstAppear case task + case onDisappear case pullToRefreshStarted case createAccountButtonTapped case settingsButtonTapped @@ -127,6 +132,9 @@ public struct Home: Sendable, FeatureReducer { @Dependency(\.overlayWindowClient) var overlayWindowClient @Dependency(\.radixConnectClient) var radixConnectClient @Dependency(\.securityCenterClient) var securityCenterClient + @Dependency(\.continuousClock) var clock + + private let accountPortfoliosRefreshIntervalInSeconds = 300 // 5 minutes public init() {} @@ -175,6 +183,10 @@ public struct Home: Sendable, FeatureReducer { .merge(with: loadFiatValues()) .merge(with: securityProblemsEffect()) .merge(with: delayedMediumEffect(for: .internal(.showLinkConnectorIfNeeded))) + .merge(with: scheduleFetchAccountPortfoliosTimer(state)) + + case .onDisappear: + return .cancel(id: CancellableId.fetchAccountPortfolios) case .createAccountButtonTapped: state.destination = .createAccount( @@ -185,12 +197,7 @@ public struct Home: Sendable, FeatureReducer { return .none case .pullToRefreshStarted: - let accountAddresses = state.accounts.map(\.address) - return .run { _ in - _ = try await accountPortfoliosClient.fetchAccountPortfolios(accountAddresses, true) - } catch: { error, _ in - errorQueue.schedule(error) - } + return fetchAccountPortfolios(state) case .radixBannerButtonTapped: return .run { _ in @@ -226,6 +233,7 @@ public struct Home: Sendable, FeatureReducer { } catch: { error, _ in errorQueue.schedule(error) } + .merge(with: scheduleFetchAccountPortfoliosTimer(state)) case let .accountsLoadedResult(.failure(error)): errorQueue.schedule(error) @@ -420,6 +428,26 @@ public struct Home: Sendable, FeatureReducer { } } } + + public func fetchAccountPortfolios(_ state: State) -> Effect { + let accountAddresses = state.accounts.map(\.address) + return .run { _ in + _ = try await accountPortfoliosClient.fetchAccountPortfolios(accountAddresses, true) + } catch: { error, _ in + errorQueue.schedule(error) + } + } + + public func scheduleFetchAccountPortfoliosTimer(_ state: State) -> Effect { + .run { _ in + for await _ in clock.timer(interval: .seconds(accountPortfoliosRefreshIntervalInSeconds)) { + guard !Task.isCancelled else { return } + let accountAddresses = state.accounts.map(\.address) + _ = try? await accountPortfoliosClient.fetchAccountPortfolios(accountAddresses, true) + } + } + .cancellable(id: CancellableId.fetchAccountPortfolios, cancelInFlight: true) + } } extension Home.State {