From fecb90d6bee2450c2cebcb694d120516212cb4c2 Mon Sep 17 00:00:00 2001 From: StephenHeaps <5314553+StephenHeaps@users.noreply.github.com> Date: Mon, 17 Jul 2023 13:43:01 -0400 Subject: [PATCH] Fix #7585: Portfolio filters and display settings (#7665) * Create FiltersDisplaySettingsView for Portfolio filters. Allows changing sort option, hiding/showing small balances, filtering by multiple accounts, and filtering by multiple networks. * Update Network Filter / Network Selection design for Wallet v2 * Update Account Filter / Account Selection design for Wallet v2 * Update to preserve Portfolio filter selections. * Add A-Z sort --- .../Crypto/Accounts/AccountFilterView.swift | 41 ++ .../Crypto/Accounts/AccountListView.swift | 81 --- .../Accounts/AccountSelectionRootView.swift | 101 ++++ .../Accounts/AccountSelectionView.swift | 71 +++ .../Crypto/BuySendSwap/AccountPicker.swift | 11 +- Sources/BraveWallet/Crypto/CryptoView.swift | 15 +- .../Crypto/FiltersDisplaySettingsView.swift | 494 ++++++++++++++++++ .../Crypto/MultipleNetworkIconsView.swift | 27 + .../Crypto/NetworkFilterView.swift | 56 +- .../Crypto/NetworkSelectionRootView.swift | 133 +++-- .../Crypto/Portfolio/PortfolioView.swift | 42 +- .../Crypto/Stores/KeyringStore.swift | 5 + .../Crypto/Stores/NetworkStore.swift | 2 +- .../Crypto/Stores/PortfolioStore.swift | 161 ++++-- .../Crypto/Stores/SettingsStore.swift | 5 + .../BraveWalletSwiftUIExtensions.swift | 4 +- Sources/BraveWallet/NetworkIcon.swift | 2 +- .../Preview Content/MockKeyringService.swift | 6 +- Sources/BraveWallet/SelectAllHeaderView.swift | 57 ++ Sources/BraveWallet/WalletPreferences.swift | 9 + Sources/BraveWallet/WalletStrings.swift | 156 +++++- .../PortfolioStoreTests.swift | 336 ++++++++++-- 22 files changed, 1568 insertions(+), 247 deletions(-) create mode 100644 Sources/BraveWallet/Crypto/Accounts/AccountFilterView.swift delete mode 100644 Sources/BraveWallet/Crypto/Accounts/AccountListView.swift create mode 100644 Sources/BraveWallet/Crypto/Accounts/AccountSelectionRootView.swift create mode 100644 Sources/BraveWallet/Crypto/Accounts/AccountSelectionView.swift create mode 100644 Sources/BraveWallet/Crypto/FiltersDisplaySettingsView.swift create mode 100644 Sources/BraveWallet/Crypto/MultipleNetworkIconsView.swift create mode 100644 Sources/BraveWallet/SelectAllHeaderView.swift diff --git a/Sources/BraveWallet/Crypto/Accounts/AccountFilterView.swift b/Sources/BraveWallet/Crypto/Accounts/AccountFilterView.swift new file mode 100644 index 00000000000..33f1775a29d --- /dev/null +++ b/Sources/BraveWallet/Crypto/Accounts/AccountFilterView.swift @@ -0,0 +1,41 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import SwiftUI +import BraveCore +import struct Shared.Strings +import BraveUI + +/// Displays all accounts and allows multiple selection for filtering by accounts. +struct AccountFilterView: View { + + @Binding var accounts: [Selectable] + + @Environment(\.presentationMode) @Binding private var presentationMode + + private var allSelected: Bool { + accounts.allSatisfy(\.isSelected) + } + + var body: some View { + AccountSelectionRootView( + navigationTitle: Strings.Wallet.selectAccountsTitle, + allAccounts: accounts.map(\.model), + selectedAccounts: accounts.filter(\.isSelected).map(\.model), + showsSelectAllButton: true, + selectAccount: selectAccount + ) + } + + private func selectAccount(_ network: BraveWallet.AccountInfo) { + DispatchQueue.main.async { + if let index = accounts.firstIndex( + where: { $0.model.id == network.id && $0.model.coin == network.coin } + ) { + accounts[index] = .init(isSelected: !accounts[index].isSelected, model: accounts[index].model) + } + } + } +} diff --git a/Sources/BraveWallet/Crypto/Accounts/AccountListView.swift b/Sources/BraveWallet/Crypto/Accounts/AccountListView.swift deleted file mode 100644 index 4b652706fbc..00000000000 --- a/Sources/BraveWallet/Crypto/Accounts/AccountListView.swift +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright 2022 The Brave Authors. All rights reserved. -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -import SwiftUI -import BraveCore -import struct Shared.Strings -import BraveUI - -struct AccountListView: View { - @ObservedObject var keyringStore: KeyringStore - - @Environment(\.presentationMode) @Binding private var presentationMode - - @State private var isPresentingAddAccount: Bool = false - - var onDismiss: () -> Void - - var body: some View { - NavigationView { - List { - Section( - header: WalletListHeaderView(title: Text(Strings.Wallet.accountsPageTitle)) - ) { - ForEach(keyringStore.allAccounts) { account in - AddressView(address: account.address) { - Button(action: { - keyringStore.selectedAccount = account - onDismiss() - }) { - AccountView(address: account.address, name: account.name) - } - } - } - .listRowBackground(Color(.secondaryBraveGroupedBackground)) - } - } - .listStyle(InsetGroupedListStyle()) - .listBackgroundColor(Color(UIColor.braveGroupedBackground)) - .navigationTitle(Strings.Wallet.selectAccountTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItemGroup(placement: .cancellationAction) { - Button(action: { onDismiss() }) { - Text(Strings.cancelButtonTitle) - .foregroundColor(Color(.braveBlurpleTint)) - } - } - ToolbarItemGroup(placement: .primaryAction) { - Button(action: { - isPresentingAddAccount = true - }) { - Label(Strings.Wallet.addAccountTitle, systemImage: "plus") - .foregroundColor(Color(.braveBlurpleTint)) - } - } - } - } - .navigationViewStyle(StackNavigationViewStyle()) - .sheet(isPresented: $isPresentingAddAccount) { - NavigationView { - AddAccountView(keyringStore: keyringStore) - } - .navigationViewStyle(StackNavigationViewStyle()) - } - } -} - -#if DEBUG -struct AccountListView_Previews: PreviewProvider { - static var previews: some View { - AccountListView(keyringStore: { - let store = KeyringStore.previewStoreWithWalletCreated - store.addPrimaryAccount("Account 2", coin: .eth, completion: nil) - store.addPrimaryAccount("Account 3", coin: .eth, completion: nil) - return store - }(), onDismiss: {}) - } -} -#endif diff --git a/Sources/BraveWallet/Crypto/Accounts/AccountSelectionRootView.swift b/Sources/BraveWallet/Crypto/Accounts/AccountSelectionRootView.swift new file mode 100644 index 00000000000..c1aab088b7b --- /dev/null +++ b/Sources/BraveWallet/Crypto/Accounts/AccountSelectionRootView.swift @@ -0,0 +1,101 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import SwiftUI +import BraveCore +import struct Shared.Strings +import BraveUI + +struct AccountSelectionRootView: View { + + let navigationTitle: String + let allAccounts: [BraveWallet.AccountInfo] + let selectedAccounts: [BraveWallet.AccountInfo] + var showsSelectAllButton: Bool + var selectAccount: (BraveWallet.AccountInfo) -> Void + + init( + navigationTitle: String, + allAccounts: [BraveWallet.AccountInfo], + selectedAccounts: [BraveWallet.AccountInfo], + showsSelectAllButton: Bool, + selectAccount: @escaping (BraveWallet.AccountInfo) -> Void + ) { + self.navigationTitle = navigationTitle + self.allAccounts = allAccounts + self.selectedAccounts = selectedAccounts + self.showsSelectAllButton = showsSelectAllButton + self.selectAccount = selectAccount + } + + var body: some View { + ScrollView { + LazyVStack(spacing: 0) { + SelectAllHeaderView( + title: Strings.Wallet.accountsPageTitle, + showsSelectAllButton: showsSelectAllButton, + allModels: allAccounts, + selectedModels: selectedAccounts, + select: selectAccount + ) + ForEach(allAccounts) { account in + AccountListRowView( + account: account, + isSelected: selectedAccounts.contains(account) + ) { + selectAccount(account) + } + } + } + } + .listBackgroundColor(Color(uiColor: WalletV2Design.containerBackground)) + .navigationTitle(navigationTitle) + .navigationBarTitleDisplayMode(.inline) + } +} + +private struct AccountListRowView: View { + + var account: BraveWallet.AccountInfo + var isSelected: Bool + let didSelect: () -> Void + + init( + account: BraveWallet.AccountInfo, + isSelected: Bool, + didSelect: @escaping () -> Void + ) { + self.account = account + self.isSelected = isSelected + self.didSelect = didSelect + } + + private var checkmark: some View { + Image(braveSystemName: "leo.check.normal") + .resizable() + .aspectRatio(contentMode: .fit) + .hidden(isHidden: !isSelected) + .foregroundColor(Color(.braveBlurpleTint)) + .frame(width: 14, height: 14) + .transition(.identity) + .animation(nil, value: isSelected) + } + + var body: some View { + AddressView(address: account.address) { + Button(action: didSelect) { + HStack { + AccountView(address: account.address, name: account.name) + checkmark + } + } + .buttonStyle(FadeButtonStyle()) + } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + .padding(.horizontal) + .contentShape(Rectangle()) + } +} diff --git a/Sources/BraveWallet/Crypto/Accounts/AccountSelectionView.swift b/Sources/BraveWallet/Crypto/Accounts/AccountSelectionView.swift new file mode 100644 index 00000000000..5b595d0193f --- /dev/null +++ b/Sources/BraveWallet/Crypto/Accounts/AccountSelectionView.swift @@ -0,0 +1,71 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import SwiftUI +import BraveCore +import struct Shared.Strings +import BraveUI + +/// Displays all accounts and will update the selected account to the account tapped on. +struct AccountSelectionView: View { + @ObservedObject var keyringStore: KeyringStore + let onDismiss: () -> Void + + @State private var isPresentingAddAccount: Bool = false + @Environment(\.presentationMode) @Binding private var presentationMode + + var body: some View { + AccountSelectionRootView( + navigationTitle: Strings.Wallet.selectAccountTitle, + allAccounts: keyringStore.allAccounts, + selectedAccounts: [keyringStore.selectedAccount], + showsSelectAllButton: false, + selectAccount: { selectedAccount in + keyringStore.selectedAccount = selectedAccount + onDismiss() + } + ) + .navigationTitle(Strings.Wallet.selectAccountTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .cancellationAction) { + Button(action: { onDismiss() }) { + Text(Strings.cancelButtonTitle) + .foregroundColor(Color(.braveBlurpleTint)) + } + } + ToolbarItemGroup(placement: .primaryAction) { + Button(action: { + isPresentingAddAccount = true + }) { + Label(Strings.Wallet.addAccountTitle, systemImage: "plus") + .foregroundColor(Color(.braveBlurpleTint)) + } + } + } + .sheet(isPresented: $isPresentingAddAccount) { + NavigationView { + AddAccountView(keyringStore: keyringStore) + } + .navigationViewStyle(.stack) + } + } +} + +#if DEBUG +struct AccountSelectionView_Previews: PreviewProvider { + static var previews: some View { + AccountSelectionView( + keyringStore: { + let store = KeyringStore.previewStoreWithWalletCreated + store.addPrimaryAccount("Account 2", coin: .eth, completion: nil) + store.addPrimaryAccount("Account 3", coin: .eth, completion: nil) + return store + }(), + onDismiss: {} + ) + } +} +#endif diff --git a/Sources/BraveWallet/Crypto/BuySendSwap/AccountPicker.swift b/Sources/BraveWallet/Crypto/BuySendSwap/AccountPicker.swift index 07664b52de5..b555066e952 100644 --- a/Sources/BraveWallet/Crypto/BuySendSwap/AccountPicker.swift +++ b/Sources/BraveWallet/Crypto/BuySendSwap/AccountPicker.swift @@ -32,10 +32,13 @@ struct AccountPicker: View { } } .sheet(isPresented: $isPresentingPicker) { - AccountListView( - keyringStore: keyringStore, - onDismiss: { isPresentingPicker = false } - ) + NavigationView { + AccountSelectionView( + keyringStore: keyringStore, + onDismiss: { isPresentingPicker = false } + ) + } + .navigationViewStyle(.stack) } } diff --git a/Sources/BraveWallet/Crypto/CryptoView.swift b/Sources/BraveWallet/Crypto/CryptoView.swift index be5ea49e6bc..5447d0f4840 100644 --- a/Sources/BraveWallet/Crypto/CryptoView.swift +++ b/Sources/BraveWallet/Crypto/CryptoView.swift @@ -111,12 +111,15 @@ public struct CryptoView: View { case .panelUnlockOrSetup: EmptyView() case .accountSelection: - AccountListView( - keyringStore: keyringStore, - onDismiss: { - dismissAction() - } - ) + NavigationView { + AccountSelectionView( + keyringStore: keyringStore, + onDismiss: { + dismissAction() + } + ) + } + .navigationViewStyle(.stack) case .transactionHistory: NavigationView { AccountTransactionListView( diff --git a/Sources/BraveWallet/Crypto/FiltersDisplaySettingsView.swift b/Sources/BraveWallet/Crypto/FiltersDisplaySettingsView.swift new file mode 100644 index 00000000000..fb03110a6f8 --- /dev/null +++ b/Sources/BraveWallet/Crypto/FiltersDisplaySettingsView.swift @@ -0,0 +1,494 @@ +/* Copyright 2021 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import SwiftUI +import BraveCore +import DesignSystem +import Preferences + +public enum GroupBy: Int, CaseIterable, Identifiable, UserDefaultsEncodable { + case none + case accounts + case networks + + var title: String { + switch self { + case .none: return Strings.Wallet.groupByNoneOptionTitle + case .accounts: return Strings.Wallet.groupByAccountsOptionTitle + case .networks: return Strings.Wallet.groupByNetworksOptionTitle + } + } + public var id: String { title } +} + +public enum SortOrder: Int, CaseIterable, Identifiable, UserDefaultsEncodable { + /// Fiat value lowest to highest + case valueAsc + /// Fiat value highest to lowest + case valueDesc + /// A-Z + case alphaAsc + /// Z-A + case alphaDesc + + var title: String { + switch self { + case .valueAsc: return Strings.Wallet.lowToHighSortOption + case .valueDesc: return Strings.Wallet.highToLowSortOption + case .alphaAsc: return Strings.Wallet.aToZSortOption + case .alphaDesc: return Strings.Wallet.zToASortOption + } + } + public var id: String { title } +} + +struct Filters { + /// How the assets should be grouped. Default is none / no grouping. + let groupBy: GroupBy + /// Ascending order is smallest fiat to largest fiat. Default is descending order. + let sortOrder: SortOrder + /// If we are hiding small balances (less than $1 value). Default is true. + let isHidingSmallBalances: Bool + /// All accounts and if they are currently selected. Default is all accounts selected. + var accounts: [Selectable] + /// All networks and if they are currently selected. Default is all selected except known test networks. + var networks: [Selectable] +} + +struct FiltersDisplaySettingsView: View { + + /// How the assets are grouped. Unavailable until Portfolio supports grouping. + @State var groupBy: GroupBy + /// Ascending order is smallest fiat to largest fiat. Default is descending order. + @State var sortOrder: SortOrder + /// If we are hiding small balances (less than $1 value). Default is false. + @State var isHidingSmallBalances: Bool + + /// All accounts and if they are currently selected. Default is all accounts selected. + @State var accounts: [Selectable] + /// All networks and if they are currently selected. Default is all selected except known test networks. + @State var networks: [Selectable] + + var networkStore: NetworkStore + let save: (Filters) -> Void + + /// Returns true if all accounts are selected + var allAccountsSelected: Bool { + accounts.allSatisfy(\.isSelected) + } + + /// Returns true if all visible networks are selected + var allNetworksSelected: Bool { + networks + .filter { + if !Preferences.Wallet.showTestNetworks.value { + return !WalletConstants.supportedTestNetworkChainIds.contains($0.model.chainId) + } + return true + } + .allSatisfy(\.isSelected) + } + + @State private var isShowingNetworksDetail: Bool = false + @Environment(\.dismiss) private var dismiss + + /// Size of the circle containing the icon for each filter. + /// The `relativeTo: .headline` should match icon's `TextStyle` in `FilterLabelView`. + @ScaledMetric(relativeTo: .headline) private var iconContainerSize: CGFloat = 40 + private var maxIconContainerSize: CGFloat = 80 + private let rowPadding: CGFloat = 16 + + init( + filters: Filters, + networkStore: NetworkStore, + save: @escaping (Filters) -> Void + ) { + self._groupBy = State(initialValue: filters.groupBy) + self._sortOrder = State(initialValue: filters.sortOrder) + self._isHidingSmallBalances = State(initialValue: filters.isHidingSmallBalances) + self._accounts = State(initialValue: filters.accounts) + self._networks = State(initialValue: filters.networks) + self.networkStore = networkStore + self.save = save + } + + var body: some View { + NavigationView { + ScrollView { + LazyVStack(spacing: 0) { + /* + Unavailable until Portfolio supports grouping. + groupByRow + .padding(.vertical, rowPadding) + */ + + sortAssets + .padding(.vertical, rowPadding) + + hideSmallBalances + .padding(.vertical, rowPadding) + + DividerLine() + + accountFilters + .padding(.vertical, rowPadding) + + networkFilters + .padding(.vertical, rowPadding) + + } + .padding(.horizontal) + } + .background(Color(uiColor: WalletV2Design.containerBackground)) + .safeAreaInset(edge: .bottom, content: { + saveChangesContainer + }) + .navigationTitle(Strings.Wallet.filtersAndDisplaySettings) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: restoreToDefaults) { + Text(Strings.Wallet.settingsResetTransactionAlertButtonTitle) + .fontWeight(.semibold) + .foregroundColor(Color(uiColor: WalletV2Design.textInteractive)) + } + } + } + } + } + + private var groupByRow: some View { + FilterPickerRowView( + title: Strings.Wallet.groupByTitle, + description: Strings.Wallet.groupByDescription, + icon: .init( + braveSystemName: "leo.list.bullet-default", + iconContainerSize: min(iconContainerSize, maxIconContainerSize) + ), + allOptions: GroupBy.allCases, + selection: $groupBy + ) { groupBy in + Text(groupBy.title) + } + } + + private var sortAssets: some View { + FilterPickerRowView( + title: Strings.Wallet.sortAssetsTitle, + description: Strings.Wallet.sortAssetsDescription, + icon: .init( + braveSystemName: "leo.arrow.down", + iconContainerSize: min(iconContainerSize, maxIconContainerSize) + ), + allOptions: SortOrder.allCases, + selection: $sortOrder + ) { sortOrder in + Text(sortOrder.title) + } + } + + private var hideSmallBalances: some View { + Toggle(isOn: $isHidingSmallBalances) { + FilterLabelView( + title: Strings.Wallet.hideSmallBalancesTitle, + description: Strings.Wallet.hideSmallBalancesDescription, + icon: .init( + braveSystemName: "leo.eye.on", + iconContainerSize: min(iconContainerSize, maxIconContainerSize) + ) + ) + } + .tint(Color(.braveBlurpleTint)) + } + + private var accountFilters: some View { + NavigationLink(destination: { + AccountFilterView( + accounts: $accounts + ) + }, label: { + FilterDetailRowView( + title: Strings.Wallet.selectAccountsTitle, + description: Strings.Wallet.selectAccountsDescription, + icon: .init( + braveSystemName: "leo.user.accounts", + iconContainerSize: iconContainerSize + ), + selectionView: { + if allAccountsSelected { + AllSelectedView(title: Strings.Wallet.allAccountsLabel) + } else if accounts.contains(where: { $0.isSelected }) { // at least 1 selected + MultipleAccountBlockiesView( + accountAddresses: accounts.filter(\.isSelected).map(\.model.address) + ) + } + } + ) + }) + .buttonStyle(FadeButtonStyle()) + } + + private var networkFilters: some View { + NavigationLink(destination: { + NetworkFilterView( + networks: networks, + networkStore: networkStore, + showsCancelButton: false, + requiresSave: false, + saveAction: { selectedNetworks in + networks = selectedNetworks + } + ) + }) { + FilterDetailRowView( + title: Strings.Wallet.selectNetworksTitle, + description: Strings.Wallet.selectNetworksDescription, + icon: .init( + braveSystemName: "leo.internet", + iconContainerSize: iconContainerSize + ), + selectionView: { + if allNetworksSelected { + AllSelectedView(title: Strings.Wallet.allNetworksLabel) + } else if networks.contains(where: { $0.isSelected }) { // at least 1 selected + MultipleNetworkIconsView( + networks: networks.filter(\.isSelected).map(\.model) + ) + } + } + ) + } + .buttonStyle(FadeButtonStyle()) + } + + private var saveChangesContainer: some View { + VStack { + Button(action: { + let filters = Filters( + groupBy: groupBy, + sortOrder: sortOrder, + isHidingSmallBalances: isHidingSmallBalances, + accounts: accounts, + networks: networks + ) + save(filters) + dismiss() + }) { + Text(Strings.Wallet.saveChangesButtonTitle) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(BraveFilledButtonStyle(size: .large)) + + Button(action: { dismiss() }) { + Text(Strings.CancelString) + .fontWeight(.semibold) + .foregroundColor(Color(uiColor: WalletV2Design.textInteractive)) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + } + .padding(.horizontal) + .padding(.vertical, 14) + .background( + Color(uiColor: WalletV2Design.containerBackground) + .ignoresSafeArea() + ) + .shadow(color: Color.black.opacity(0.04), radius: 16, x: 0, y: -8) + } + + func restoreToDefaults() { + self.groupBy = .none + // Fiat value in descending order (largest fiat to smallest) by default + self.sortOrder = .valueDesc + // Small balances shown by default + self.isHidingSmallBalances = false + + // All accounts selected by default + self.accounts = self.accounts.map { + .init(isSelected: true, model: $0.model) + } + // All non-test networks selected by default + self.networks = self.networks.map { + let isTestnet = WalletConstants.supportedTestNetworkChainIds.contains($0.model.chainId) + return .init(isSelected: !isTestnet, model: $0.model) + } + } + + func selectAllAccounts() { + self.accounts = self.accounts.map { + .init(isSelected: true, model: $0.model) + } + } + + func selectAllNetworks() { + self.networks = self.networks.map { + .init(isSelected: true, model: $0.model) + } + } +} + +#if DEBUG +struct FiltersDisplaySettingsView_Previews: PreviewProvider { + static var previews: some View { + FiltersDisplaySettingsView( + filters: Filters( + groupBy: .none, + sortOrder: .valueDesc, + isHidingSmallBalances: false, + accounts: [ + .init(isSelected: true, model: .mockEthAccount), + .init(isSelected: true, model: .mockSolAccount) + ], + networks: [ + .init(isSelected: true, model: .mockMainnet), + .init(isSelected: true, model: .mockSolana), + .init(isSelected: true, model: .mockPolygon), + .init(isSelected: false, model: .mockSolanaTestnet), + .init(isSelected: false, model: .mockGoerli) + ] + ), + networkStore: .previewStore, + save: { _ in } + ) + } +} +#endif + +private struct AllSelectedView: View { + let title: String + + var body: some View { + Text(title) + .foregroundColor(Color(uiColor: WalletV2Design.extendedGray50)) + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 2) + .padding(4) + .background(Color(uiColor: WalletV2Design.extendedGray20)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} + +struct FilterIconInfo { + let braveSystemName: String + let iconContainerSize: CGFloat +} + +// View with icon, title and description. +private struct FilterLabelView: View { + + let title: String + let description: String + let icon: FilterIconInfo? + + var body: some View { + HStack { + if let icon { + Color(uiColor: WalletV2Design.containerHighlight) + .clipShape(Circle()) + .frame(width: icon.iconContainerSize, height: icon.iconContainerSize) + .overlay { + Image(braveSystemName: icon.braveSystemName) + .imageScale(.medium) + .font(.headline) + .foregroundColor(Color(uiColor: WalletV2Design.iconDefault)) + } + } + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.body.weight(.semibold)) + .foregroundColor(Color(uiColor: WalletV2Design.text01)) + Text(description) + .font(.footnote) + .foregroundColor(Color(uiColor: WalletV2Design.textSecondary)) + } + .multilineTextAlignment(.leading) + } + } +} + +// `FilterLabelView` with a detail disclosure. +private struct FilterDetailRowView: View { + + let title: String + let description: String + let icon: FilterIconInfo? + @ViewBuilder let selectionView: () -> SelectionView + + var body: some View { + HStack { + FilterLabelView( + title: title, + description: description, + icon: icon + ) + Spacer() + selectionView() + Image(systemName: "chevron.right") + .font(.body.weight(.semibold)) + .foregroundColor(Color(.separator)) + } + } +} + +/// Displays provided options in a context menu allowing a single selection. +struct FilterPickerRowView: View { + + let title: String + let description: String + let icon: FilterIconInfo? + + let allOptions: [T] + @Binding var selection: T + let content: (T) -> Content + + var body: some View { + HStack { + FilterLabelView( + title: title, + description: description, + icon: icon + ) + Spacer() + Menu(content: { + ForEach(allOptions) { option in + Button(action: { selection = option }) { + HStack { + Image(braveSystemName: "leo.check.normal") + .resizable() + .aspectRatio(contentMode: .fit) + .hidden(isHidden: selection.id != option.id) + content(option) + } + } + } + }, label: { + HStack(spacing: 8) { + content(selection) + Image(braveSystemName: "leo.carat.down") + } + }) + .foregroundColor(Color(WalletV2Design.textInteractive)) + } + } +} + +struct DividerLine: View { + var body: some View { + Color(uiColor: WalletV2Design.dividerSubtle) + .frame(height: 1) + } +} + +struct FadeButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .opacity(configuration.isPressed ? 0.7 : 1.0) + .clipShape(Rectangle()) + } +} diff --git a/Sources/BraveWallet/Crypto/MultipleNetworkIconsView.swift b/Sources/BraveWallet/Crypto/MultipleNetworkIconsView.swift new file mode 100644 index 00000000000..b5f7734ae96 --- /dev/null +++ b/Sources/BraveWallet/Crypto/MultipleNetworkIconsView.swift @@ -0,0 +1,27 @@ +/* Copyright 2023 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import SwiftUI +import BraveCore + +struct MultipleNetworkIconsView: View { + let networks: [BraveWallet.NetworkInfo] + let maxIcons = 3 + @ScaledMetric var iconSize = 16.0 + var maxIconSize: CGFloat = 32 + @ScaledMetric var iconDotSize = 2.0 + + var body: some View { + MultipleCircleIconView( + models: networks, + iconSize: iconSize, + maxIconSize: maxIconSize, + iconDotSize: iconDotSize, + iconView: { network in + NetworkIcon(network: network, length: iconSize) + } + ) + } +} diff --git a/Sources/BraveWallet/Crypto/NetworkFilterView.swift b/Sources/BraveWallet/Crypto/NetworkFilterView.swift index 53d66c245e0..fad52d22961 100644 --- a/Sources/BraveWallet/Crypto/NetworkFilterView.swift +++ b/Sources/BraveWallet/Crypto/NetworkFilterView.swift @@ -19,6 +19,8 @@ struct NetworkFilterView: View { @State var networks: [Selectable] @ObservedObject var networkStore: NetworkStore + let showsCancelButton: Bool + let requiresSave: Bool let saveAction: ([Selectable]) -> Void @Environment(\.presentationMode) @Binding private var presentationMode @@ -26,10 +28,14 @@ struct NetworkFilterView: View { init( networks: [Selectable], networkStore: NetworkStore, + showsCancelButton: Bool = true, + requiresSave: Bool = true, saveAction: @escaping ([Selectable]) -> Void ) { self._networks = .init(initialValue: networks) self.networkStore = networkStore + self.showsCancelButton = showsCancelButton + self.requiresSave = requiresSave self.saveAction = saveAction } @@ -49,26 +55,27 @@ struct NetworkFilterView: View { navigationTitle: Strings.Wallet.networkFilterTitle, selectedNetworks: networks.filter(\.isSelected).map(\.model), allNetworks: networks.map(\.model), + showsCancelButton: showsCancelButton, + showsSelectAllButton: true, selectNetwork: selectNetwork ) .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(action: { - saveAction(networks) - presentationMode.dismiss() - }) { - Text(Strings.Wallet.saveButtonTitle) - .foregroundColor(Color(.braveBlurpleTint)) + if requiresSave { + Button(action: { + saveAction(networks) + presentationMode.dismiss() + }) { + Text(Strings.Wallet.saveButtonTitle) + .foregroundColor(Color(.braveBlurpleTint)) + } } } } - .toolbar { - ToolbarItemGroup(placement: .bottomBar) { - Spacer() - Button(action: selectAll) { - Text(allSelected ? Strings.Wallet.deselectAllButtonTitle : Strings.Wallet.selectAllButtonTitle) - .foregroundColor(Color(.braveBlurpleTint)) - } + .onChange(of: networks) { networks in + if !requiresSave { + // No save button, so call saveAction when updating selections + saveAction(networks) } } } @@ -97,3 +104,26 @@ struct NetworkFilterView: View { } } } + +#if DEBUG +struct NetworkFilterView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + NetworkFilterView( + networks: [ + .init(isSelected: true, model: .mockMainnet), + .init(isSelected: true, model: .mockSolana), + .init(isSelected: true, model: .mockPolygon), + .init(isSelected: true, model: .mockGoerli), + .init(isSelected: true, model: .mockSolanaTestnet) + ], + networkStore: .previewStore, + requiresSave: false, + saveAction: { _ in + + } + ) + } + } +} +#endif diff --git a/Sources/BraveWallet/Crypto/NetworkSelectionRootView.swift b/Sources/BraveWallet/Crypto/NetworkSelectionRootView.swift index ff5e1b27921..638d2b100e2 100644 --- a/Sources/BraveWallet/Crypto/NetworkSelectionRootView.swift +++ b/Sources/BraveWallet/Crypto/NetworkSelectionRootView.swift @@ -12,97 +12,155 @@ struct NetworkSelectionRootView: View { var navigationTitle: String var selectedNetworks: [BraveWallet.NetworkInfo] var allNetworks: [BraveWallet.NetworkInfo] + var showsCancelButton: Bool + var showsSelectAllButton: Bool var selectNetwork: (BraveWallet.NetworkInfo) -> Void @Environment(\.presentationMode) @Binding private var presentationMode + init( + navigationTitle: String, + selectedNetworks: [BraveWallet.NetworkInfo], + allNetworks: [BraveWallet.NetworkInfo], + showsCancelButton: Bool = true, + showsSelectAllButton: Bool = false, + selectNetwork: @escaping (BraveWallet.NetworkInfo) -> Void + ) { + self.navigationTitle = navigationTitle + self.selectedNetworks = selectedNetworks + self.allNetworks = allNetworks + self.showsCancelButton = showsCancelButton + self.showsSelectAllButton = showsSelectAllButton + self.selectNetwork = selectNetwork + } + var body: some View { - List { - Section { + ScrollView { + LazyVStack(spacing: 0) { + SelectAllHeaderView( + title: Strings.Wallet.networkSelectionPrimaryNetworks, + showsSelectAllButton: showsSelectAllButton, + allModels: allNetworks.primaryNetworks, + selectedModels: selectedNetworks, + select: selectNetwork + ) ForEach(allNetworks.primaryNetworks) { network in Button(action: { selectNetwork(network) }) { NetworkRowView( network: network, - selectedNetworks: selectedNetworks + isSelected: selectedNetworks.contains(network) ) } - .listRowBackground(Color(.secondaryBraveGroupedBackground)) + .buttonStyle(FadeButtonStyle()) } - } - Section(content: { + + DividerLine() + .padding(.top, 12) + + SelectAllHeaderView( + title: Strings.Wallet.networkSelectionSecondaryNetworks, + showsSelectAllButton: showsSelectAllButton, + allModels: allNetworks.secondaryNetworks, + selectedModels: selectedNetworks, + select: selectNetwork + ) ForEach(allNetworks.secondaryNetworks) { network in Button(action: { selectNetwork(network) }) { NetworkRowView( network: network, - selectedNetworks: selectedNetworks + isSelected: selectedNetworks.contains(network) ) } - .listRowBackground(Color(.secondaryBraveGroupedBackground)) + .buttonStyle(FadeButtonStyle()) } - }, header: { - WalletListHeaderView(title: Text(Strings.Wallet.networkSelectionSecondaryNetworks)) - }) - if Preferences.Wallet.showTestNetworks.value && !allNetworks.testNetworks.isEmpty { - Section(content: { + + if Preferences.Wallet.showTestNetworks.value && !allNetworks.testNetworks.isEmpty { + DividerLine() + .padding(.top, 12) + + SelectAllHeaderView( + title: Strings.Wallet.networkSelectionTestNetworks, + showsSelectAllButton: showsSelectAllButton, + allModels: allNetworks.testNetworks, + selectedModels: selectedNetworks, + select: selectNetwork + ) ForEach(allNetworks.testNetworks) { network in Button(action: { selectNetwork(network) }) { NetworkRowView( network: network, - selectedNetworks: selectedNetworks + isSelected: selectedNetworks.contains(network) ) } - .listRowBackground(Color(.secondaryBraveGroupedBackground)) + .buttonStyle(FadeButtonStyle()) } - }, header: { - WalletListHeaderView(title: Text(Strings.Wallet.networkSelectionTestNetworks)) - }) + } } } - .listStyle(.insetGrouped) - .listBackgroundColor(Color(UIColor.braveGroupedBackground)) + .listBackgroundColor(Color(uiColor: WalletV2Design.containerBackground)) .navigationTitle(navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .cancellationAction) { - Button(action: { presentationMode.dismiss() }) { - Text(Strings.cancelButtonTitle) - .foregroundColor(Color(.braveBlurpleTint)) + if showsCancelButton { + Button(action: { presentationMode.dismiss() }) { + Text(Strings.cancelButtonTitle) + .foregroundColor(Color(.braveBlurpleTint)) + } } } } } } +#if DEBUG +struct NetworkSelectionRootView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + NetworkSelectionRootView( + navigationTitle: Strings.Wallet.selectNetworksTitle, + selectedNetworks: [.mockMainnet, .mockSolana, .mockPolygon], + allNetworks: [ + .mockMainnet, .mockSolana, + .mockPolygon, .mockCelo, + .mockGoerli, .mockSolanaTestnet + ], + selectNetwork: { _ in + + } + ) + } + } +} +#endif + private struct NetworkRowView: View { var network: BraveWallet.NetworkInfo - var selectedNetworks: [BraveWallet.NetworkInfo] + var isSelected: Bool @ScaledMetric private var length: CGFloat = 30 init( network: BraveWallet.NetworkInfo, - selectedNetworks: [BraveWallet.NetworkInfo] + isSelected: Bool ) { self.network = network - self.selectedNetworks = selectedNetworks - } - - private var isSelected: Bool { - selectedNetworks.contains(where: { $0.chainId == network.chainId }) + self.isSelected = isSelected } private var checkmark: some View { Image(braveSystemName: "leo.check.normal") .resizable() .aspectRatio(contentMode: .fit) - .opacity(isSelected ? 1 : 0) + .hidden(isHidden: !isSelected) .foregroundColor(Color(.braveBlurpleTint)) .frame(width: 14, height: 14) + .transition(.identity) + .animation(nil, value: isSelected) } var body: some View { HStack { - checkmark NetworkIcon(network: network) VStack(alignment: .leading, spacing: 0) { Text(network.chainName) @@ -110,12 +168,13 @@ private struct NetworkRowView: View { } .frame(minHeight: length) // maintain height for All Networks row w/o icon Spacer() + checkmark } .accessibilityElement(children: .combine) .accessibilityAddTraits(isSelected ? [.isSelected] : []) .foregroundColor(Color(.braveLabel)) - .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.vertical, 12) .contentShape(Rectangle()) } } @@ -126,15 +185,15 @@ struct NetworkRowView_Previews: PreviewProvider { Group { NetworkRowView( network: .mockSolana, - selectedNetworks: [.mockSolana] + isSelected: true ) NetworkRowView( network: .mockMainnet, - selectedNetworks: [.mockMainnet] + isSelected: true ) NetworkRowView( network: .mockPolygon, - selectedNetworks: [.mockMainnet] + isSelected: false ) } .previewLayout(.sizeThatFits) diff --git a/Sources/BraveWallet/Crypto/Portfolio/PortfolioView.swift b/Sources/BraveWallet/Crypto/Portfolio/PortfolioView.swift index f5a3725eaac..9d84a62bae9 100644 --- a/Sources/BraveWallet/Crypto/Portfolio/PortfolioView.swift +++ b/Sources/BraveWallet/Crypto/Portfolio/PortfolioView.swift @@ -12,6 +12,7 @@ import Strings import DesignSystem import BraveUI import Shared +import Preferences struct PortfolioView: View { var cryptoStore: CryptoStore @@ -22,7 +23,7 @@ struct PortfolioView: View { @State private var dismissedBackupBannerThisSession: Bool = false @State private var isPresentingBackup: Bool = false @State private var isPresentingEditUserAssets: Bool = false - @State private var isPresentingNetworkFilter: Bool = false + @State private var isPresentingFiltersDisplaySettings: Bool = false @Environment(\.sizeCategory) private var sizeCategory @Environment(\.buySendSwapDestination) @@ -93,29 +94,34 @@ struct PortfolioView: View { } } - private var networkFilterButton: some View { + private var filtersButton: some View { Button(action: { - self.isPresentingNetworkFilter = true + self.isPresentingFiltersDisplaySettings = true }) { Image(braveSystemName: "leo.tune") .font(.footnote.weight(.medium)) .foregroundColor(Color(.braveBlurpleTint)) .clipShape(Rectangle()) } - .sheet(isPresented: $isPresentingNetworkFilter) { - NavigationView { - NetworkFilterView( - networks: portfolioStore.networkFilters, - networkStore: networkStore, - saveAction: { selectedNetworks in - portfolioStore.networkFilters = selectedNetworks - } - ) - } - .navigationViewStyle(.stack) - .onDisappear { - networkStore.closeNetworkSelectionStore() - } + .sheet(isPresented: $isPresentingFiltersDisplaySettings) { + FiltersDisplaySettingsView( + filters: portfolioStore.filters, + networkStore: networkStore, + save: { filters in + portfolioStore.saveFilters(filters) + } + ) + .osAvailabilityModifiers({ view in + if #available(iOS 16, *) { + view + .presentationDetents([ + .fraction(0.6), + .large + ]) + } else { + view + } + }) } } @@ -159,7 +165,7 @@ struct PortfolioView: View { .padding(.leading, 5) } Spacer() - networkFilterButton + filtersButton } .textCase(nil) .padding(.horizontal, -8) diff --git a/Sources/BraveWallet/Crypto/Stores/KeyringStore.swift b/Sources/BraveWallet/Crypto/Stores/KeyringStore.swift index 43de3dfed15..16fd9d23f96 100644 --- a/Sources/BraveWallet/Crypto/Stores/KeyringStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/KeyringStore.swift @@ -12,6 +12,7 @@ import Strings import LocalAuthentication import Combine import Data +import Preferences struct AutoLockInterval: Identifiable, Hashable { var value: Int32 @@ -309,6 +310,10 @@ public class KeyringStore: ObservableObject { for coin in WalletConstants.supportedCoinTypes { Domain.clearAllWalletPermissions(for: coin) } + Preferences.Wallet.sortOrderFilter.reset() + Preferences.Wallet.isHidingSmallBalancesFilter.reset() + Preferences.Wallet.nonSelectedAccountsFilter.reset() + Preferences.Wallet.nonSelectedNetworksFilter.reset() completion?(isMnemonicValid) } } diff --git a/Sources/BraveWallet/Crypto/Stores/NetworkStore.swift b/Sources/BraveWallet/Crypto/Stores/NetworkStore.swift index 52e7584f800..27502106c0d 100644 --- a/Sources/BraveWallet/Crypto/Stores/NetworkStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/NetworkStore.swift @@ -98,7 +98,7 @@ public class NetworkStore: ObservableObject { self.allChains = await rpcService.allNetworksForSupportedCoins() let customChainIds = await rpcService.customNetworks(.eth) // only support Ethereum custom chains - self.customChains = allChains.filter { customChainIds.contains($0.id) } + self.customChains = allChains.filter { customChainIds.contains($0.chainId) } } func isCustomChain(_ network: BraveWallet.NetworkInfo) -> Bool { diff --git a/Sources/BraveWallet/Crypto/Stores/PortfolioStore.swift b/Sources/BraveWallet/Crypto/Stores/PortfolioStore.swift index bc0d6b60d83..e57483afe0a 100644 --- a/Sources/BraveWallet/Crypto/Stores/PortfolioStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/PortfolioStore.swift @@ -8,6 +8,7 @@ import BraveCore import SwiftUI import Combine import Data +import Preferences public struct AssetViewModel: Identifiable, Equatable { var token: BraveWallet.BlockchainToken @@ -21,19 +22,32 @@ public struct AssetViewModel: Identifiable, Equatable { } /// Sort by the fiat/value of the asset (price x balance), otherwise by balance when price is unavailable. - static func sortedByValue(lhs: AssetViewModel, rhs: AssetViewModel) -> Bool { - if let lhsPrice = Double(lhs.price), - let rhsPrice = Double(rhs.price) { - return (lhsPrice * lhs.decimalBalance) > (rhsPrice * rhs.decimalBalance) - } else if let lhsPrice = Double(lhs.price), (lhsPrice * lhs.decimalBalance) > 0 { - // lhs has a non-zero value - return true - } else if let rhsPrice = Double(rhs.price), (rhsPrice * rhs.decimalBalance) > 0 { - // rhs has a non-zero value - return false + static func sorted(by sortOrder: SortOrder = .valueDesc, lhs: AssetViewModel, rhs: AssetViewModel) -> Bool { + switch sortOrder { + case .valueAsc, .valueDesc: + if let lhsPrice = Double(lhs.price), + let rhsPrice = Double(rhs.price) { + if sortOrder == .valueAsc { + return (lhsPrice * lhs.decimalBalance) < (rhsPrice * rhs.decimalBalance) + } + return (lhsPrice * lhs.decimalBalance) > (rhsPrice * rhs.decimalBalance) + } else if let lhsPrice = Double(lhs.price), (lhsPrice * lhs.decimalBalance) > 0 { + // lhs has a non-zero value + return true + } else if let rhsPrice = Double(rhs.price), (rhsPrice * rhs.decimalBalance) > 0 { + // rhs has a non-zero value + return false + } + // price unavailable, sort by balance + if sortOrder == .valueAsc { + return lhs.decimalBalance < rhs.decimalBalance + } + return lhs.decimalBalance > rhs.decimalBalance + case .alphaAsc: + return lhs.token.name.localizedStandardCompare(rhs.token.name) == .orderedAscending + case .alphaDesc: + return lhs.token.name.localizedStandardCompare(rhs.token.name) == .orderedDescending } - // price unavailable, sort by balance - return lhs.decimalBalance > rhs.decimalBalance } } @@ -74,13 +88,36 @@ public class PortfolioStore: ObservableObject { } } - @Published var networkFilters: [Selectable] = [] { - didSet { - guard !oldValue.isEmpty else { return } // initial assignment to `networkFilters` - update() - } - } @Published private(set) var isLoadingDiscoverAssets: Bool = false + + /// All User Accounts + var allAccounts: [BraveWallet.AccountInfo] = [] + /// All available networks + var allNetworks: [BraveWallet.NetworkInfo] = [] + var filters: Filters { + let nonSelectedAccountAddresses = Preferences.Wallet.nonSelectedAccountsFilter.value + let nonSelectedNetworkChainIds = Preferences.Wallet.nonSelectedNetworksFilter.value + return Filters( + groupBy: .none, + sortOrder: SortOrder(rawValue: Preferences.Wallet.sortOrderFilter.value) ?? .valueDesc, + isHidingSmallBalances: Preferences.Wallet.isHidingSmallBalancesFilter.value, + accounts: allAccounts.map { account in + .init( + isSelected: !nonSelectedAccountAddresses.contains(where: { $0 == account.address }), + model: account + ) + }, + networks: allNetworks.map { network in + .init( + isSelected: !nonSelectedNetworkChainIds.contains(where: { $0 == network.chainId }), + model: network + ) + } + ) + } + /// Flag indicating when we are saving filters. Since we are observing multiple `Preference.Option`s, + /// we should avoid calling `update()` in `preferencesDidChange()` unless another view changed. + private var isSavingFilters: Bool = false public private(set) lazy var userAssetsStore: UserAssetsStore = .init( blockchainRegistry: self.blockchainRegistry, @@ -139,30 +176,46 @@ public class PortfolioStore: ObservableObject { walletService.defaultBaseCurrency { [self] currencyCode in self.currencyCode = currencyCode } + Preferences.Wallet.showTestNetworks.observe(from: self) + Preferences.Wallet.sortOrderFilter.observe(from: self) + Preferences.Wallet.isHidingSmallBalancesFilter.observe(from: self) + Preferences.Wallet.nonSelectedAccountsFilter.observe(from: self) + Preferences.Wallet.nonSelectedNetworksFilter.observe(from: self) } func update() { self.updateTask?.cancel() self.updateTask = Task { @MainActor in self.isLoadingBalances = true - // setup network filters if not currently setup - if self.networkFilters.isEmpty { - self.networkFilters = await self.rpcService.allNetworksForSupportedCoins().map { - .init(isSelected: !WalletConstants.supportedTestNetworkChainIds.contains($0.chainId), model: $0) + self.allAccounts = await keyringService.allAccounts().accounts + self.allNetworks = await rpcService.allNetworksForSupportedCoins().filter { network in + if !Preferences.Wallet.showTestNetworks.value { // filter out test networks + return !WalletConstants.supportedTestNetworkChainIds.contains(where: { $0 == network.chainId }) } + return true } - let networks: [BraveWallet.NetworkInfo] = self.networkFilters.filter(\.isSelected).map(\.model) + let filters = self.filters + let selectedAcounts = filters.accounts.filter(\.isSelected).map(\.model) + let selectedNetworks = filters.networks.filter(\.isSelected).map(\.model) struct NetworkAssets: Equatable { let network: BraveWallet.NetworkInfo let tokens: [BraveWallet.BlockchainToken] let sortOrder: Int } - let allVisibleUserAssets = assetManager.getAllVisibleAssetsInNetworkAssets(networks: networks) + let allVisibleUserAssets = assetManager.getAllVisibleAssetsInNetworkAssets(networks: selectedNetworks) var updatedUserVisibleAssets = buildAssetViewModels(allVisibleUserAssets: allVisibleUserAssets) // update userVisibleAssets on display immediately with empty values. Issue #5567 self.userVisibleAssets = updatedUserVisibleAssets - .sorted(by: AssetViewModel.sortedByValue(lhs:rhs:)) - let keyrings = await self.keyringService.keyrings(for: WalletConstants.supportedCoinTypes) + .optionallyFilter( + shouldFilter: filters.isHidingSmallBalances, + isIncluded: { assetViewModel in + let value = (Double(assetViewModel.price) ?? 0) * assetViewModel.decimalBalance + return value >= 1 + } + ) + .sorted(by: { lhs, rhs in + AssetViewModel.sorted(by: filters.sortOrder, lhs: lhs, rhs: rhs) + }) guard !Task.isCancelled else { return } typealias TokenNetworkAccounts = (token: BraveWallet.BlockchainToken, network: BraveWallet.NetworkInfo, accounts: [BraveWallet.AccountInfo]) let allTokenNetworkAccounts = allVisibleUserAssets.flatMap { networkAssets in @@ -170,7 +223,7 @@ public class PortfolioStore: ObservableObject { TokenNetworkAccounts( token: token, network: networkAssets.network, - accounts: keyrings.first(where: { $0.coin == token.coin })?.accountInfos ?? [] + accounts: selectedAcounts.filter { $0.coin == token.coin } ) } } @@ -219,7 +272,16 @@ public class PortfolioStore: ObservableObject { guard !Task.isCancelled else { return } updatedUserVisibleAssets = buildAssetViewModels(allVisibleUserAssets: allVisibleUserAssets) self.userVisibleAssets = updatedUserVisibleAssets - .sorted(by: AssetViewModel.sortedByValue(lhs:rhs:)) + .optionallyFilter( + shouldFilter: filters.isHidingSmallBalances, + isIncluded: { assetViewModel in + let value = (Double(assetViewModel.price) ?? 0) * assetViewModel.decimalBalance + return value >= 1 + } + ) + .sorted(by: { lhs, rhs in + AssetViewModel.sorted(by: filters.sortOrder, lhs: lhs, rhs: rhs) + }) // Compute balance based on current prices let currentBalance = userVisibleAssets @@ -311,7 +373,10 @@ extension PortfolioStore: BraveWalletKeyringServiceObserver { } public func accountsChanged() { - update() + Task { @MainActor in + // An account was added or removed, `update()` will update `allAccounts`. + update() + } } public func backedUp() { } @@ -354,12 +419,8 @@ extension PortfolioStore: BraveWalletBraveWalletServiceObserver { public func onNetworkListChanged() { Task { @MainActor in - // A network was added or removed, update our network filters for the change. - self.networkFilters = await self.rpcService.allNetworksForSupportedCoins().map { network in - let defaultValue = !WalletConstants.supportedTestNetworkChainIds.contains(network.chainId) - let existingSelectionValue = self.networkFilters.first(where: { $0.model.chainId == network.chainId})?.isSelected - return .init(isSelected: existingSelectionValue ?? defaultValue, model: network) - } + // A network was added or removed, `update()` will update `allNetworks`. + update() } } @@ -381,3 +442,33 @@ extension PortfolioStore: BraveWalletBraveWalletServiceObserver { public func onResetWallet() { } } + +extension PortfolioStore: PreferencesObserver { + func saveFilters(_ filters: Filters) { + isSavingFilters = true + defer { + isSavingFilters = false + update() + } + Preferences.Wallet.sortOrderFilter.value = filters.sortOrder.rawValue + Preferences.Wallet.isHidingSmallBalancesFilter.value = filters.isHidingSmallBalances + Preferences.Wallet.nonSelectedAccountsFilter.value = filters.accounts + .filter({ !$0.isSelected }) + .map(\.model.address) + Preferences.Wallet.nonSelectedNetworksFilter.value = filters.networks + .filter({ !$0.isSelected }) + .map(\.model.chainId) + } + public func preferencesDidChange(for key: String) { + guard !isSavingFilters else { return } + update() + } +} + +extension Array { + /// `filter` helper that skips iterating through the entire array when not applying any filtering. + @inlinable public func optionallyFilter(shouldFilter: Bool, isIncluded: (Element) throws -> Bool) rethrows -> [Element] { + guard shouldFilter else { return self } + return try filter(isIncluded) + } +} diff --git a/Sources/BraveWallet/Crypto/Stores/SettingsStore.swift b/Sources/BraveWallet/Crypto/Stores/SettingsStore.swift index 58f43bd9ed8..6ad5557700c 100644 --- a/Sources/BraveWallet/Crypto/Stores/SettingsStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/SettingsStore.swift @@ -128,6 +128,11 @@ public class SettingsStore: ObservableObject { Preferences.Wallet.displayWeb3Notifications.reset() Preferences.Wallet.migrateCoreToWalletUserAssetCompleted.reset() + // Portfolio/NFT Filters + Preferences.Wallet.sortOrderFilter.reset() + Preferences.Wallet.isHidingSmallBalancesFilter.reset() + Preferences.Wallet.nonSelectedAccountsFilter.reset() + Preferences.Wallet.nonSelectedNetworksFilter.reset() WalletUserAssetGroup.removeAllGroup() } diff --git a/Sources/BraveWallet/Extensions/BraveWalletSwiftUIExtensions.swift b/Sources/BraveWallet/Extensions/BraveWalletSwiftUIExtensions.swift index bd1bb088033..5896e4d776c 100644 --- a/Sources/BraveWallet/Extensions/BraveWalletSwiftUIExtensions.swift +++ b/Sources/BraveWallet/Extensions/BraveWalletSwiftUIExtensions.swift @@ -9,7 +9,7 @@ import BraveCore extension BraveWallet.AccountInfo: Identifiable { public var id: String { - address + "\(address)\(coin.rawValue)" } public var isPrimary: Bool { // no hardware support on iOS @@ -44,7 +44,7 @@ public enum AssetImageName: String { extension BraveWallet.NetworkInfo: Identifiable { public var id: String { - chainId + "\(chainId)\(coin.rawValue)" } var shortChainName: String { diff --git a/Sources/BraveWallet/NetworkIcon.swift b/Sources/BraveWallet/NetworkIcon.swift index fd3587b3d95..324f9d1cc4a 100644 --- a/Sources/BraveWallet/NetworkIcon.swift +++ b/Sources/BraveWallet/NetworkIcon.swift @@ -11,7 +11,7 @@ struct NetworkIcon: View { var network: BraveWallet.NetworkInfo - @ScaledMetric private var length: CGFloat = 30 + @ScaledMetric var length: CGFloat = 30 var body: some View { Group { diff --git a/Sources/BraveWallet/Preview Content/MockKeyringService.swift b/Sources/BraveWallet/Preview Content/MockKeyringService.swift index ff1d97124fe..4f5f3ec3af8 100644 --- a/Sources/BraveWallet/Preview Content/MockKeyringService.swift +++ b/Sources/BraveWallet/Preview Content/MockKeyringService.swift @@ -362,7 +362,7 @@ extension BraveWallet.AccountInfo { uniqueKey: "mock_eth_id" ), address: "mock_eth_id", - name: "mock_eth_name", + name: "Ethereum Account 1", hardware: nil ) @@ -375,7 +375,7 @@ extension BraveWallet.AccountInfo { uniqueKey: "mock_sol_id" ), address: "mock_sol_id", - name: "mock_sol_name", + name: "Solana Account 1", hardware: nil ) @@ -388,7 +388,7 @@ extension BraveWallet.AccountInfo { uniqueKey: "mock_fil_id" ), address: "mock_fil_id", - name: "mock_fil_name", + name: "Filecoin Account 1", hardware: nil ) } diff --git a/Sources/BraveWallet/SelectAllHeaderView.swift b/Sources/BraveWallet/SelectAllHeaderView.swift new file mode 100644 index 00000000000..0672af53b91 --- /dev/null +++ b/Sources/BraveWallet/SelectAllHeaderView.swift @@ -0,0 +1,57 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import SwiftUI + +/// Header view for Wallet v2 with an optional `Select All` / `Deselect All` button. +struct SelectAllHeaderView: View { + + let title: String + let showsSelectAllButton: Bool + let allModels: [T] + let selectedModels: [T] + let select: (T) -> Void + + var body: some View { + HStack { + Text(title) + .font(.body.weight(.semibold)) + .foregroundColor(Color(uiColor: WalletV2Design.textPrimary)) + Spacer() + if showsSelectAllButton { + Button(action: selectAll) { + Text(selectAllButtonTitle(allSelected)) + .font(.callout.weight(.semibold)) + .foregroundColor(Color(uiColor: WalletV2Design.textInteractive)) + } + } + } + .padding(.horizontal) + .padding(.vertical, 12) + } + + private var allSelected: Bool { + allModels.allSatisfy({ model in + selectedModels.contains(where: { $0.id == model.id }) + }) + } + + private func selectAllButtonTitle(_ allSelected: Bool) -> String { + if allSelected { + return Strings.Wallet.deselectAllButtonTitle + } + return Strings.Wallet.selectAllButtonTitle + } + + private func selectAll() { + if allSelected { // deselect all + allModels.forEach(select) + } else { // select all + for model in allModels where !selectedModels.contains(where: { $0.id == model.id }) { + select(model) + } + } + } +} diff --git a/Sources/BraveWallet/WalletPreferences.swift b/Sources/BraveWallet/WalletPreferences.swift index c958aeb04e4..ea8dde630f8 100644 --- a/Sources/BraveWallet/WalletPreferences.swift +++ b/Sources/BraveWallet/WalletPreferences.swift @@ -47,6 +47,15 @@ extension Preferences { /// The option for users to turn off aurora popup public static let showAuroraPopup = Option(key: "wallet.show-aurora-popup", default: true) + // MARK: Portfolio & NFT filters + public static let sortOrderFilter = Option(key: "wallet.sortOrderFilter", default: SortOrder.valueDesc.rawValue) + public static let isHidingSmallBalancesFilter = Option(key: "wallet.isHidingSmallBalancesFilter", default: false) + public static let nonSelectedAccountsFilter = Option<[String]>(key: "wallet.nonSelectedAccountsFilter", default: []) + public static let nonSelectedNetworksFilter = Option<[String]>( + key: "wallet.nonSelectedNetworksFilter", + default: WalletConstants.supportedTestNetworkChainIds + ) + /// Reset Wallet Preferences based on coin type public static func reset(for coin: BraveWallet.CoinType) { switch coin { diff --git a/Sources/BraveWallet/WalletStrings.swift b/Sources/BraveWallet/WalletStrings.swift index b5b3091ebad..53f781b96e7 100644 --- a/Sources/BraveWallet/WalletStrings.swift +++ b/Sources/BraveWallet/WalletStrings.swift @@ -2568,7 +2568,7 @@ extension Strings { tableName: "BraveWallet", bundle: .module, value: "None", - comment: "The value shown when selecting the default wallet as none / no wallet in wallet settings." + comment: "The value shown when selecting the default wallet as none / no wallet in wallet settings, or when grouping Portfolio assets." ) public static let walletPanelUnlockWallet = NSLocalizedString( "wallet.walletPanelUnlockWallet", @@ -2822,6 +2822,13 @@ extension Strings { value: "Select Network", comment: "The title of the to select a network from the available networks" ) + public static let networkSelectionPrimaryNetworks = NSLocalizedString( + "wallet.networkSelectionPrimaryNetworks", + tableName: "BraveWallet", + bundle: .module, + value: "Primary Networks", + comment: "The title of the section for primary networks in the network selection view." + ) public static let networkSelectionSecondaryNetworks = NSLocalizedString( "wallet.networkSelectionSecondaryNetworks", tableName: "BraveWallet", @@ -4114,5 +4121,152 @@ extension Strings { value: "Deselect All", comment: "The title of a button that Deselects all visible options." ) + public static let filtersAndDisplaySettings = NSLocalizedString( + "wallet.filtersAndDisplaySettings", + tableName: "BraveWallet", + bundle: .module, + value: "Filters and Display Settings", + comment: "The title of the modal for filtering Portfolio and NFT views." + ) + public static let lowToHighSortOption = NSLocalizedString( + "wallet.lowToHighSortOption", + tableName: "BraveWallet", + bundle: .module, + value: "Low to High", + comment: "The title of the sort option that sorts lowest value to highest value. Used in Portfolio/NFT filters and display settings." + ) + public static let highToLowSortOption = NSLocalizedString( + "wallet.highToLowSortOption", + tableName: "BraveWallet", + bundle: .module, + value: "High to Low", + comment: "The title of the sort option that sorts highest value to lowest value. Used in Portfolio/NFT filters and display settings." + ) + public static let aToZSortOption = NSLocalizedString( + "wallet.aToZSortOption", + tableName: "BraveWallet", + bundle: .module, + value: "A to Z", + comment: "The title of the sort option that sorts alphabetically from A to Z. Used in Portfolio/NFT filters and display settings." + ) + public static let zToASortOption = NSLocalizedString( + "wallet.highToLowSortOption", + tableName: "BraveWallet", + bundle: .module, + value: "Z to A", + comment: "The title of the sort option that sorts alphabetically from Z to A. Used in Portfolio/NFT filters and display settings." + ) + public static let sortAssetsTitle = NSLocalizedString( + "wallet.sortAssetsTitle", + tableName: "BraveWallet", + bundle: .module, + value: "Sort Assets", + comment: "The label of the sort option that sorts assets by their fiat value. Used in Portfolio/NFT filters and display settings." + ) + public static let sortAssetsDescription = NSLocalizedString( + "wallet.sortAssetsDescription", + tableName: "BraveWallet", + bundle: .module, + value: "Sort by fiat value or name", + comment: "The description label of the sort option that sorts assets by their fiat value, shown below the title. Used in Portfolio/NFT filters and display settings." + ) + public static let hideSmallBalancesTitle = NSLocalizedString( + "wallet.hideSmallBalancesTitle", + tableName: "BraveWallet", + bundle: .module, + value: "Hide Small Balances", + comment: "The label of the filter option that hides assets if their fiat value is below $1. Used in Portfolio/NFT filters and display settings." + ) + public static let hideSmallBalancesDescription = NSLocalizedString( + "wallet.hideSmallBalancesDescription", + tableName: "BraveWallet", + bundle: .module, + value: "Assets with value less than $1", + comment: "The description label of the filter option that hides assets if their fiat value is below $1, shown below the title. Used in Portfolio/NFT filters and display settings." + ) + public static let selectAccountsTitle = NSLocalizedString( + "wallet.selectAccountsTitle", + tableName: "BraveWallet", + bundle: .module, + value: "Select Accounts", + comment: "The label of the filter option that allows users to select which accounts to filter assets by. Used in Portfolio/NFT filters and display settings." + ) + public static let selectAccountsDescription = NSLocalizedString( + "wallet.selectAccountsDescription", + tableName: "BraveWallet", + bundle: .module, + value: "Select accounts to filter by", + comment: "The description label of the filter option that allows users to select which accounts to filter assets by, shown below the title. Used in Portfolio/NFT filters and display settings." + ) + public static let allAccountsLabel = NSLocalizedString( + "wallet.allAccountsLabel", + tableName: "BraveWallet", + bundle: .module, + value: "All accounts", + comment: "The label of badge beside the filter option that allows users to select which accounts to filter assets by, when all accounts are selected. Used in Portfolio/NFT filters and display settings." + ) + public static let selectNetworksTitle = NSLocalizedString( + "wallet.selectNetworksTitle", + tableName: "BraveWallet", + bundle: .module, + value: "Select Networks", + comment: "The label of the filter option that allows users to select which networks to filter assets by. Used in Portfolio/NFT filters and display settings." + ) + public static let selectNetworksDescription = NSLocalizedString( + "wallet.selectNetworksDescription", + tableName: "BraveWallet", + bundle: .module, + value: "Select networks to filter by", + comment: "The description label of the filter option that allows users to select which networks to filter assets by, shown below the title. Used in Portfolio/NFT filters and display settings." + ) + public static let allNetworksLabel = NSLocalizedString( + "wallet.allNetworksLabel", + tableName: "BraveWallet", + bundle: .module, + value: "All accounts", + comment: "The label of badge beside the filter option that allows users to select which accounts to filter assets by, when all accounts are selected. Used in Portfolio/NFT filters and display settings." + ) + public static let saveChangesButtonTitle = NSLocalizedString( + "wallet.saveChangesButtonTitle", + tableName: "BraveWallet", + bundle: .module, + value: "Save Changes", + comment: "The title of the label of the button to save all changes. Used in Portfolio/NFT filters and display settings." + ) + public static let groupByTitle = NSLocalizedString( + "wallet.groupByTitle", + tableName: "BraveWallet", + bundle: .module, + value: "Group By", + comment: "The label of the sort option that groups assets by the selected filter value. Used in Portfolio/NFT filters and display settings." + ) + public static let groupByDescription = NSLocalizedString( + "wallet.groupByDescription", + tableName: "BraveWallet", + bundle: .module, + value: "Group assets by", + comment: "The description label of the sort option that groups assets by the selected filter value, shown below the title. Used in Portfolio/NFT filters and display settings." + ) + public static let groupByNoneOptionTitle = NSLocalizedString( + "wallet.groupByNoneOptionTitle", + tableName: "BraveWallet", + bundle: .module, + value: "None", + comment: "The title of the sort option that does not group assets. Used in Portfolio/NFT filters and display settings." + ) + public static let groupByAccountsOptionTitle = NSLocalizedString( + "wallet.groupByAccountsOptionTitle", + tableName: "BraveWallet", + bundle: .module, + value: "Accounts", + comment: "The title of the sort option that groups assets by each account. Used in Portfolio/NFT filters and display settings." + ) + public static let groupByNetworksOptionTitle = NSLocalizedString( + "wallet.groupByNetworksTitle", + tableName: "BraveWallet", + bundle: .module, + value: "Networks", + comment: "The title of the sort option that groups assets by each network. Used in Portfolio/NFT filters and display settings." + ) } } diff --git a/Tests/BraveWalletTests/PortfolioStoreTests.swift b/Tests/BraveWalletTests/PortfolioStoreTests.swift index 2eacd44127f..57702e2fcde 100644 --- a/Tests/BraveWalletTests/PortfolioStoreTests.swift +++ b/Tests/BraveWalletTests/PortfolioStoreTests.swift @@ -6,17 +6,32 @@ import Combine import XCTest import BraveCore +import Preferences @testable import BraveWallet class PortfolioStoreTests: XCTestCase { private var cancellables: Set = .init() + override func setUp() { + resetFilters() + } + override func tearDown() { + resetFilters() + } + private func resetFilters() { + Preferences.Wallet.sortOrderFilter.reset() + Preferences.Wallet.isHidingSmallBalancesFilter.reset() + Preferences.Wallet.nonSelectedAccountsFilter.reset() + Preferences.Wallet.nonSelectedNetworksFilter.reset() + } + /// Test `update()` will fetch all visible user assets from all networks and display them sorted by their balance. - func testUpdate() { + func testUpdate() async { let mockETHBalance: Double = 0.896 let mockETHPrice: String = "3059.99" // ETH value = $2741.75104 - let mockUSDCBalance: Double = 4 + let mockUSDCBalanceAccount1: Double = 0.5 + let mockUSDCBalanceAccount2: Double = 0.25 let mockUSDCPrice: String = "1" // USDC value = $4 let mockSOLLamportBalance: UInt64 = 3876535000 // ~3.8765 SOL let mockSOLBalance: Double = 3.8765 // lamports rounded @@ -42,7 +57,12 @@ class PortfolioStoreTests: XCTestCase { let totalSolBalanceValue: Double = (Double(mockSOLAssetPrice.price) ?? 0) * mockSOLBalance // config Ethereum - let mockEthAccountInfos: [BraveWallet.AccountInfo] = [.mockEthAccount] + let ethAccount1: BraveWallet.AccountInfo = .mockEthAccount + let ethAccount2 = (BraveWallet.AccountInfo.mockEthAccount.copy() as! BraveWallet.AccountInfo).then { + $0.address = "mock_eth_id_2" + $0.name = "Ethereum Account 2" + } + let mockEthAccountInfos: [BraveWallet.AccountInfo] = [ethAccount1, ethAccount2] let ethNetwork: BraveWallet.NetworkInfo = .mockMainnet let mockEthUserAssets: [BraveWallet.BlockchainToken] = [ .previewToken.copy(asVisibleAsset: true), @@ -55,8 +75,13 @@ class PortfolioStoreTests: XCTestCase { radix: .hex, decimals: Int(BraveWallet.BlockchainToken.previewToken.decimals) ) ?? "" - let usdcBalanceWei = formatter.weiString( - from: mockUSDCBalance, + let usdcAccount1BalanceWei = formatter.weiString( + from: mockUSDCBalanceAccount1, + radix: .hex, + decimals: Int(BraveWallet.BlockchainToken.mockUSDCToken.decimals) + ) ?? "" + let usdcAccount2BalanceWei = formatter.weiString( + from: mockUSDCBalanceAccount2, radix: .hex, decimals: Int(BraveWallet.BlockchainToken.mockUSDCToken.decimals) ) ?? "" @@ -74,39 +99,38 @@ class PortfolioStoreTests: XCTestCase { .init(date: Date(), price: mockUSDCPrice) ] let totalEthBalanceValue: Double = (Double(mockETHAssetPrice.price) ?? 0) * mockETHBalance - let totalUSDCBalanceValue: Double = (Double(mockUSDCAssetPrice.price) ?? 0) * mockUSDCBalance + var totalUSDCBalanceValue: Double = 0 + totalUSDCBalanceValue += (Double(mockUSDCAssetPrice.price) ?? 0) * mockUSDCBalanceAccount1 + totalUSDCBalanceValue += (Double(mockUSDCAssetPrice.price) ?? 0) * mockUSDCBalanceAccount2 let totalBalanceValue = totalEthBalanceValue + totalSolBalanceValue + totalUSDCBalanceValue let totalBalance = currencyFormatter.string(from: NSNumber(value: totalBalanceValue)) ?? "" + let ethKeyring: BraveWallet.KeyringInfo = .init( + id: BraveWallet.KeyringId.default, + isKeyringCreated: true, + isLocked: false, + isBackedUp: true, + accountInfos: mockEthAccountInfos + ) + let solKeyring: BraveWallet.KeyringInfo = .init( + id: BraveWallet.KeyringId.solana, + isKeyringCreated: true, + isLocked: false, + isBackedUp: true, + accountInfos: mockSolAccountInfos + ) + // setup test services let keyringService = BraveWallet.TestKeyringService() - keyringService._keyringInfo = { keyringId, completion in - if keyringId == BraveWallet.KeyringId.default { - let keyring: BraveWallet.KeyringInfo = .init( - id: BraveWallet.KeyringId.default, - isKeyringCreated: true, - isLocked: false, - isBackedUp: true, - accountInfos: mockEthAccountInfos - ) - completion(keyring) - } else { - let keyring: BraveWallet.KeyringInfo = .init( - id: BraveWallet.KeyringId.solana, - isKeyringCreated: true, - isLocked: false, - isBackedUp: true, - accountInfos: mockSolAccountInfos - ) - completion(keyring) - } - } keyringService._addObserver = { _ in } keyringService._isLocked = { completion in // unlocked would cause `update()` from call in `init` to be called prior to test being setup.g completion(true) } + keyringService._allAccounts = { + $0(.init(accounts: ethKeyring.accountInfos + solKeyring.accountInfos)) + } let rpcService = BraveWallet.TestJsonRpcService() rpcService._addObserver = { _ in } rpcService._allNetworks = { coin, completion in @@ -123,14 +147,29 @@ class PortfolioStoreTests: XCTestCase { XCTFail("Should not fetch unknown network") } } - rpcService._balance = { _, _, _, completion in - completion(ethBalanceWei, .success, "") // eth balance + rpcService._balance = { accountAddress, _, _, completion in + // eth balance + if accountAddress == ethAccount1.address { + completion(ethBalanceWei, .success, "") + } else { + completion("", .success, "") + } } - rpcService._erc20TokenBalance = { contractAddress, _, _, completion in - completion(usdcBalanceWei, .success, "") // usdc balance + rpcService._erc20TokenBalance = { contractAddress, accountAddress, _, completion in + // usdc balance + if accountAddress == ethAccount1.address { + completion(usdcAccount1BalanceWei, .success, "") + } else { + completion(usdcAccount2BalanceWei, .success, "") + } } - rpcService._erc721TokenBalance = { contractAddress, _, _, _, completion in - completion(mockNFTBalanceWei, .success, "") // eth nft balance + rpcService._erc721TokenBalance = { contractAddress, _, accountAddress, _, completion in + // eth nft balance + if accountAddress == ethAccount1.address { + completion(mockNFTBalanceWei, .success, "") + } else { + completion("", .success, "") + } } rpcService._solanaBalance = { accountAddress, chainId, completion in completion(mockSOLLamportBalance, .success, "") // sol balance @@ -180,12 +219,12 @@ class PortfolioStoreTests: XCTestCase { } let mockAssetManager = TestableWalletUserAssetManager() - mockAssetManager._getAllVisibleAssetsInNetworkAssets = { _ in - [NetworkAssets(network: .mockMainnet, tokens: mockEthUserAssets.filter({ $0.visible == true }), sortOrder: 0), - NetworkAssets(network: .mockSolana, tokens: mockSolUserAssets.filter({ $0.visible == true }), sortOrder: 1) - ] + mockAssetManager._getAllVisibleAssetsInNetworkAssets = { networks in + [ + NetworkAssets(network: .mockMainnet, tokens: mockEthUserAssets.filter({ $0.visible == true }), sortOrder: 0), + NetworkAssets(network: .mockSolana, tokens: mockSolUserAssets.filter({ $0.visible == true }), sortOrder: 1) + ].filter { networkAsset in networks.contains(where: { $0 == networkAsset.network }) } } - // setup store let store = PortfolioStore( keyringService: keyringService, @@ -196,7 +235,8 @@ class PortfolioStoreTests: XCTestCase { ipfsApi: TestIpfsAPI(), userAssetManager: mockAssetManager ) - // test that `update()` will assign new value to `userVisibleAssets` publisher + + // MARK: Default update() Test let userVisibleAssetsException = expectation(description: "update-userVisibleAssets") XCTAssertTrue(store.userVisibleAssets.isEmpty) // Initial state store.$userVisibleAssets @@ -211,27 +251,33 @@ class PortfolioStoreTests: XCTestCase { } // ETH on Ethereum mainnet, SOL on Solana mainnet, USDC on Ethereum mainnet XCTAssertEqual(lastUpdatedVisibleAssets.count, 3) - // ETH + // ETH (value ~= $2741.7510399999996) XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.token.symbol, BraveWallet.BlockchainToken.previewToken.symbol) XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.price, mockETHAssetPrice.price) XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.history, mockETHPriceHistory) - // SOL + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.decimalBalance, + mockETHBalance) + // SOL (value = $775.3) XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.token.symbol, BraveWallet.BlockchainToken.mockSolToken.symbol) XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.price, mockSOLAssetPrice.price) XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.history, mockSOLPriceHistory) - // USDC first with largest balance + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.decimalBalance, + mockSOLBalance) + // USDC (value $0.5) XCTAssertEqual(lastUpdatedVisibleAssets[safe: 2]?.token.symbol, BraveWallet.BlockchainToken.mockUSDCToken.symbol) XCTAssertEqual(lastUpdatedVisibleAssets[safe: 2]?.price, mockUSDCAssetPrice.price) XCTAssertEqual(lastUpdatedVisibleAssets[safe: 2]?.history, mockUSDCPriceHistory) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 2]?.decimalBalance, + mockUSDCBalanceAccount1 + mockUSDCBalanceAccount2) }.store(in: &cancellables) // test that `update()` will assign new value to `balance` publisher let balanceException = expectation(description: "update-balance") @@ -256,9 +302,209 @@ class PortfolioStoreTests: XCTestCase { } .store(in: &cancellables) store.update() - waitForExpectations(timeout: 1) { error in - XCTAssertNil(error) - } + await fulfillment(of: [userVisibleAssetsException, balanceException, isLoadingBalancesException], timeout: 1) + cancellables.removeAll() + + // MARK: Sort Order Filter Test (Smallest value first) + let sortExpectation = expectation(description: "update-sortOrder") + store.$userVisibleAssets + .dropFirst() + .collect(2) + .sink { userVisibleAssets in + defer { sortExpectation.fulfill() } + XCTAssertEqual(userVisibleAssets.count, 2) // empty assets, populated assets + guard let lastUpdatedVisibleAssets = userVisibleAssets.last else { + XCTFail("Unexpected test result") + return + } + // USDC on Ethereum mainnet, SOL on Solana mainnet, ETH on Ethereum mainnet + XCTAssertEqual(lastUpdatedVisibleAssets.count, 3) + // USDC (value $0.75) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.token.symbol, + BraveWallet.BlockchainToken.mockUSDCToken.symbol) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.price, + mockUSDCAssetPrice.price) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.history, + mockUSDCPriceHistory) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.decimalBalance, + mockUSDCBalanceAccount1 + mockUSDCBalanceAccount2) + // SOL (value = $775.3) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.token.symbol, + BraveWallet.BlockchainToken.mockSolToken.symbol) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.price, + mockSOLAssetPrice.price) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.history, + mockSOLPriceHistory) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.decimalBalance, + mockSOLBalance) + // ETH (value ~= $2741.7510399999996) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 2]?.token.symbol, + BraveWallet.BlockchainToken.previewToken.symbol) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 2]?.price, + mockETHAssetPrice.price) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 2]?.history, + mockETHPriceHistory) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 2]?.decimalBalance, + mockETHBalance) + }.store(in: &cancellables) + + // change sort to ascending + store.saveFilters(.init( + groupBy: store.filters.groupBy, + sortOrder: .valueAsc, + isHidingSmallBalances: store.filters.isHidingSmallBalances, + accounts: store.filters.accounts, + networks: store.filters.networks + )) + await fulfillment(of: [sortExpectation], timeout: 1) + cancellables.removeAll() + + // MARK: Hide Small Balances Test (tokens with value < $1 hidden) + let hideSmallBalancesExpectation = expectation(description: "update-hideSmallBalances") + store.$userVisibleAssets + .dropFirst() + .collect(2) + .sink { userVisibleAssets in + defer { hideSmallBalancesExpectation.fulfill() } + XCTAssertEqual(userVisibleAssets.count, 2) // empty assets, populated assets + guard let lastUpdatedVisibleAssets = userVisibleAssets.last else { + XCTFail("Unexpected test result") + return + } + // ETH on Ethereum mainnet, SOL on Solana mainnet + XCTAssertEqual(lastUpdatedVisibleAssets.count, 2) // USDC hidden for small balance + // ETH (value ~= 2741.7510399999996) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.token.symbol, + BraveWallet.BlockchainToken.previewToken.symbol) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.price, + mockETHAssetPrice.price) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.history, + mockETHPriceHistory) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.decimalBalance, + mockETHBalance) + // SOL (value = 775.3) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.token.symbol, + BraveWallet.BlockchainToken.mockSolToken.symbol) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.price, + mockSOLAssetPrice.price) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.history, + mockSOLPriceHistory) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.decimalBalance, + mockSOLBalance) + // USDC (value 0.75), hidden + XCTAssertNil(lastUpdatedVisibleAssets[safe: 2]) + }.store(in: &cancellables) + store.saveFilters(.init( + groupBy: store.filters.groupBy, + sortOrder: .valueDesc, + isHidingSmallBalances: true, + accounts: store.filters.accounts, + networks: store.filters.networks + )) + await fulfillment(of: [hideSmallBalancesExpectation], timeout: 1) + cancellables.removeAll() + + // MARK: Account Filter Test (Ethereum account 2 de-selected) + let accountsExpectation = expectation(description: "update-accounts") + store.$userVisibleAssets + .dropFirst() + .collect(2) + .sink { userVisibleAssets in + defer { accountsExpectation.fulfill() } + XCTAssertEqual(userVisibleAssets.count, 2) // empty assets, populated assets + guard let lastUpdatedVisibleAssets = userVisibleAssets.last else { + XCTFail("Unexpected test result") + return + } + // ETH on Ethereum mainnet, SOL on Solana mainnet, USDC on Ethereum mainnet + XCTAssertEqual(lastUpdatedVisibleAssets.count, 3) + // ETH (value ~= 2741.7510399999996) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.token.symbol, + BraveWallet.BlockchainToken.previewToken.symbol) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.price, + mockETHAssetPrice.price) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.history, + mockETHPriceHistory) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.decimalBalance, + mockETHBalance) + // SOL (value = 775.3) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.token.symbol, + BraveWallet.BlockchainToken.mockSolToken.symbol) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.price, + mockSOLAssetPrice.price) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.history, + mockSOLPriceHistory) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.decimalBalance, + mockSOLBalance) + // USDC (value 0.5, ethAccount2 hidden!) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 2]?.token.symbol, + BraveWallet.BlockchainToken.mockUSDCToken.symbol) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 2]?.price, + mockUSDCAssetPrice.price) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 2]?.history, + mockUSDCPriceHistory) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 2]?.decimalBalance, + mockUSDCBalanceAccount1) // verify account 2 hidden + }.store(in: &cancellables) + store.saveFilters(.init( + groupBy: store.filters.groupBy, + sortOrder: .valueDesc, + isHidingSmallBalances: false, + accounts: store.filters.accounts.map { // deselect ethAccount2 + .init(isSelected: $0.model.address != ethAccount2.address, model: $0.model) + }, + networks: store.filters.networks + )) + await fulfillment(of: [accountsExpectation], timeout: 1) + cancellables.removeAll() + + // MARK: Network Filter Test (Solana networks de-selected) + let networksExpectation = expectation(description: "update-networks") + store.$userVisibleAssets + .dropFirst() + .collect(2) + .sink { userVisibleAssets in + defer { networksExpectation.fulfill() } + XCTAssertEqual(userVisibleAssets.count, 2) // empty assets, populated assets + guard let lastUpdatedVisibleAssets = userVisibleAssets.last else { + XCTFail("Unexpected test result") + return + } + // ETH on Ethereum mainnet, USDC on Ethereum mainnet + XCTAssertEqual(lastUpdatedVisibleAssets.count, 2) + // ETH (value ~= 2741.7510399999996) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.token.symbol, + BraveWallet.BlockchainToken.previewToken.symbol) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.price, + mockETHAssetPrice.price) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.history, + mockETHPriceHistory) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 0]?.decimalBalance, + mockETHBalance) + // USDC (value 0.75) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.token.symbol, + BraveWallet.BlockchainToken.mockUSDCToken.symbol) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.price, + mockUSDCAssetPrice.price) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.history, + mockUSDCPriceHistory) + XCTAssertEqual(lastUpdatedVisibleAssets[safe: 1]?.decimalBalance, + mockUSDCBalanceAccount1 + mockUSDCBalanceAccount2) + // SOL (value = 0, SOL networks hidden) + XCTAssertNil(lastUpdatedVisibleAssets[safe: 2]) + }.store(in: &cancellables) + store.saveFilters(.init( + groupBy: store.filters.groupBy, + sortOrder: .valueDesc, + isHidingSmallBalances: false, + accounts: store.filters.accounts.map { // re-select all accounts + .init(isSelected: true, model: $0.model) + }, + networks: store.filters.networks.map { // only select Ethereum networks + .init(isSelected: $0.model.coin == .eth, model: $0.model) + } + )) + await fulfillment(of: [networksExpectation], timeout: 1) } }