From 6872792432d157b7eff288198e8414d050003051 Mon Sep 17 00:00:00 2001 From: StephenHeaps <5314553+StephenHeaps@users.noreply.github.com> Date: Mon, 22 Jan 2024 15:27:44 -0500 Subject: [PATCH] Fix #8600: Accounts Tab v2 (#8659) * Move Backup to `...` menu, and Add/Create Account specific for Accounts tab. * Update `AccountsView` to v2 designs, create `AccountsStore` for building account cards with balances and price information. * Create `AccountsStoreTests` unit test for fetching accounts and tokens with balance / total fiat for each account * Card background using blockie material, refresh list on network change & show/hide test networks, small UI, tweaks * Split `AssetIconView` into `AssetIcon` (no sizing) and `AssetIconView` (applies dynamic sizing). Split `NetworkIcon` into `NetworkIcon` (no sizing) and `NetworkIconView` (applies dynamic sizing). Update `MultipleCircleIconView` to apply a background instead of just a stroke for when transparent icons are used. * Add border to `...` icon in `MultipleCircleIconView` for consistency. --- .../Crypto/Accounts/AccountsView.swift | 352 +++++++++++++----- .../Accounts/Details/AccountDetailsView.swift | 1 - .../BraveWallet/Crypto/AssetIconView.swift | 82 ++-- .../BraveWallet/Crypto/CryptoTabsView.swift | 123 ++++-- Sources/BraveWallet/Crypto/MainMenuView.swift | 35 +- .../Crypto/MultipleAssetIconsView.swift | 31 ++ .../Crypto/MultipleNetworkIconsView.swift | 2 +- .../Crypto/NetworkSelectionRootView.swift | 2 +- .../Crypto/Portfolio/PortfolioView.swift | 2 +- .../Crypto/Stores/AccountsStore.swift | 262 +++++++++++++ .../Crypto/Stores/CryptoStore.swift | 8 + .../PendingTransactionView.swift | 2 +- .../SaferSignTransactionContainerView.swift | 2 +- .../Extensions/BraveWalletExtensions.swift | 23 +- .../BraveWallet/MultipleCircleIconView.swift | 64 ++-- Sources/BraveWallet/NetworkIcon.swift | 26 +- .../Preview Content/MockStores.swift | 12 + Sources/BraveWallet/WalletStrings.swift | 47 ++- .../Contents.json | 11 + .../BraveWalletTests/AccountsStoreTests.swift | 245 ++++++++++++ 20 files changed, 1122 insertions(+), 210 deletions(-) create mode 100644 Sources/BraveWallet/Crypto/MultipleAssetIconsView.swift create mode 100644 Sources/BraveWallet/Crypto/Stores/AccountsStore.swift create mode 100644 Sources/DesignSystem/Icons/Symbols.xcassets/leo.web3.blockexplorer.symbolset/Contents.json create mode 100644 Tests/BraveWalletTests/AccountsStoreTests.swift diff --git a/Sources/BraveWallet/Crypto/Accounts/AccountsView.swift b/Sources/BraveWallet/Crypto/Accounts/AccountsView.swift index 76760e53dc16..a72540d5f59e 100644 --- a/Sources/BraveWallet/Crypto/Accounts/AccountsView.swift +++ b/Sources/BraveWallet/Crypto/Accounts/AccountsView.swift @@ -11,86 +11,66 @@ import BraveUI import Strings struct AccountsView: View { + @ObservedObject var store: AccountsStore var cryptoStore: CryptoStore - @ObservedObject var keyringStore: KeyringStore - @State private var selectedAccount: BraveWallet.AccountInfo? - @State private var isPresentingBackup: Bool = false - @State private var isPresentingAddAccount: Bool = false - - private var primaryAccounts: [BraveWallet.AccountInfo] { - keyringStore.allAccounts.filter(\.isPrimary) - } - - private var secondaryAccounts: [BraveWallet.AccountInfo] { - keyringStore.allAccounts.filter(\.isImported) - } - + var keyringStore: KeyringStore + /// When populated, account activity pushed for given account (assets, transactions) + @State private var selectedAccountActivity: BraveWallet.AccountInfo? + /// When populated, account info presented modally for given account (rename, export private key) + @State private var selectedAccountForEdit: BraveWallet.AccountInfo? + + @State private var selectedAccountForExport: BraveWallet.AccountInfo? + var body: some View { - List { - Section( - header: AccountsHeaderView( - keyringStore: keyringStore, - settingsStore: cryptoStore.settingsStore, - networkStore: cryptoStore.networkStore, - isPresentingBackup: $isPresentingBackup, - isPresentingAddAccount: $isPresentingAddAccount - ) - .resetListHeaderStyle() - ) { - } - Section( - header: WalletListHeaderView( - title: Text(Strings.Wallet.primaryCryptoAccountsTitle) - ) - ) { - ForEach(primaryAccounts) { account in - Button { - selectedAccount = account - } label: { - AddressView(address: account.address) { - AccountView(address: account.address, name: account.name) - } + ScrollView { + LazyVStack(spacing: 16) { + Section(content: { + ForEach(store.primaryAccounts) { accountDetails in + AccountCardView( + account: accountDetails.account, + tokensWithBalances: accountDetails.tokensWithBalance, + balance: accountDetails.totalBalanceFiat, + isLoading: store.isLoading, + action: { action in + handle(action: action, for: accountDetails.account) + } + ) } - } - .listRowBackground(Color(.secondaryBraveGroupedBackground)) - } - Section( - header: WalletListHeaderView( - title: Text(Strings.Wallet.secondaryCryptoAccountsTitle), - subtitle: Text(Strings.Wallet.secondaryCryptoAccountsSubtitle) - ) - ) { - Group { - let accounts = secondaryAccounts - if accounts.isEmpty { - Text(Strings.Wallet.noSecondaryAccounts) - .foregroundColor(Color(.secondaryBraveLabel)) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .font(.footnote.weight(.medium)) - } else { - ForEach(accounts) { account in - Button { - selectedAccount = account - } label: { - AddressView(address: account.address) { - AccountView(address: account.address, name: account.name) + }) + + if !store.importedAccounts.isEmpty { + Section(content: { + ForEach(store.importedAccounts) { accountDetails in + AccountCardView( + account: accountDetails.account, + tokensWithBalances: accountDetails.tokensWithBalance, + balance: accountDetails.totalBalanceFiat, + isLoading: store.isLoading, + action: { action in + handle(action: action, for: accountDetails.account) } - } + ) } - } + }, header: { + Text(Strings.Wallet.importedCryptoAccountsTitle) + .font(.headline.weight(.semibold)) + .foregroundColor(Color(braveSystemName: .textPrimary)) + .frame(maxWidth: .infinity, alignment: .leading) + }) } - .listRowBackground(Color(.secondaryBraveGroupedBackground)) } + .padding(16) } + .navigationTitle(Strings.Wallet.accountsPageTitle) + .navigationBarTitleDisplayMode(.inline) .background( NavigationLink( isActive: Binding( - get: { selectedAccount != nil }, - set: { if !$0 { selectedAccount = nil } } + get: { selectedAccountActivity != nil }, + set: { if !$0 { selectedAccountActivity = nil } } ), destination: { - if let account = selectedAccount { + if let account = selectedAccountActivity { AccountActivityView( keyringStore: keyringStore, activityStore: cryptoStore.accountActivityStore( @@ -108,34 +88,65 @@ struct AccountsView: View { EmptyView() }) ) - .listStyle(InsetGroupedListStyle()) - .listBackgroundColor(Color(UIColor.braveGroupedBackground)) .background( Color.clear - .sheet(isPresented: $isPresentingBackup) { - NavigationView { - BackupWalletView( - password: nil, - keyringStore: keyringStore + .sheet(isPresented: Binding( + get: { selectedAccountForEdit != nil }, + set: { if !$0 { selectedAccountForEdit = nil } } + )) { + if let account = selectedAccountForEdit { + AccountDetailsView( + keyringStore: keyringStore, + account: account, + editMode: true ) } - .navigationViewStyle(StackNavigationViewStyle()) - .environment(\.modalPresentationMode, $isPresentingBackup) - .accentColor(Color(.braveBlurpleTint)) } ) .background( Color.clear - .sheet(isPresented: $isPresentingAddAccount) { - NavigationView { - AddAccountView( - keyringStore: keyringStore, - networkStore: cryptoStore.networkStore - ) + .sheet(isPresented: Binding( + get: { selectedAccountForExport != nil }, + set: { if !$0 { selectedAccountForExport = nil } } + )) { + if let account = selectedAccountForExport { + NavigationView { + AccountPrivateKeyView( + keyringStore: keyringStore, + account: account + ) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { + selectedAccountForExport = nil + }) { + Text(Strings.cancelButtonTitle) + .foregroundColor(Color(.braveBlurpleTint)) + } + } + } + } } - .navigationViewStyle(StackNavigationViewStyle()) } ) + .onAppear { + store.update() + } + } + + private func handle(action: AccountCardView.Action, for account: BraveWallet.AccountInfo) { + switch action { + case .viewDetails: + selectedAccountActivity = account + case .editDetails: + selectedAccountForEdit = account + case .viewOnBlockExplorer: + break + case .exportAccount: + selectedAccountForExport = account + case .depositToAccount: + break + } } } @@ -144,6 +155,7 @@ struct AccountsViewController_Previews: PreviewProvider { static var previews: some View { Group { AccountsView( + store: .previewStore, cryptoStore: .previewStore, keyringStore: .previewStoreWithWalletCreated ) @@ -153,3 +165,175 @@ struct AccountsViewController_Previews: PreviewProvider { } } #endif + +private struct AccountCardView: View { + + enum Action: Equatable { + case viewDetails + case editDetails + case viewOnBlockExplorer + case exportAccount + case depositToAccount + } + + let account: BraveWallet.AccountInfo + let tokensWithBalances: [BraveWallet.BlockchainToken] + let balance: String + let isLoading: Bool + let action: (Action) -> Void + + @Environment(\.colorScheme) private var colorScheme: ColorScheme + @ScaledMetric private var avatarSize = 40.0 + private let maxAvatarSize: CGFloat = 80.0 + private let contentPadding: CGFloat = 16 + + /// Content for the top section of the card. Buttons may be hidden so we can overlay + /// the buttons on the card itself to not interfere with touches on the card itself. + private func topSectionContent(hidingButtons: Bool = true) -> some View { + HStack { + HStack(spacing: 8) { + Blockie(address: account.address) + .frame(width: min(avatarSize, maxAvatarSize), height: min(avatarSize, maxAvatarSize)) + VStack(alignment: .leading) { + AddressView(address: account.address) { + // VStack keeps views together when showing context menu w/ address + VStack(alignment: .leading) { + Text(account.name) + .font(.headline.weight(.semibold)) + .foregroundColor(Color(braveSystemName: .textPrimary)) + Text(account.address.truncatedAddress) + .font(.footnote) + } + } + Text(account.accountSupportDisplayString) + .font(.footnote) + } + .foregroundColor(Color(braveSystemName: .textSecondary)) + } + .hidden(isHidden: !hidingButtons) + Spacer() + + // buttons can be hidden so it's used in layout, but displayed + // in overlay to not interfere with row button touches. + buttons + .hidden(isHidden: hidingButtons) + } + .padding(contentPadding) + } + + private var buttons: some View { + HStack(spacing: 12) { + /* + TODO: Accounts Block Explorer #8638 + Button(action: { + action(.viewOnBlockExplorer) + }) { + RoundedRectangle(cornerRadius: 8) + .stroke(Color(braveSystemName: .dividerInteractive), lineWidth: 1) + .frame(width: 36, height: 36) + .overlay { + Image(braveSystemName: "leo.web3.blockexplorer") + .foregroundColor(Color(braveSystemName: .iconInteractive)) + } + } + */ + Menu { + Button(action: { + action(.viewDetails) + }) { + Label(Strings.Wallet.viewDetails, braveSystemImage: "leo.eye.on") + } + Button(action: { + action(.editDetails) + }) { + Label(Strings.Wallet.editButtonTitle, braveSystemImage: "leo.edit.pencil") + } + Divider() + Button(action: { + action(.exportAccount) + }) { + Label(Strings.Wallet.exportButtonTitle, braveSystemImage: "leo.key") + } + /* + TODO: Account Deposit UI #8639 + Button(action: { + action(.depositToAccount) + }) { + Label("Deposit", braveSystemImage: "leo.qr.code") + } + */ + } label: { + RoundedRectangle(cornerRadius: 8) + .stroke(Color(braveSystemName: .dividerInteractive), lineWidth: 1) + .frame(width: 36, height: 36) + .overlay { + Image(braveSystemName: "leo.more.horizontal") + .foregroundColor(Color(braveSystemName: .iconInteractive)) + } + } + } + } + + private var bottomSectionContent: some View { + HStack { + if isLoading && tokensWithBalances.isEmpty { + RoundedRectangle(cornerRadius: 4) + .fill(Color(white: 0.9)) + .frame(width: 48, height: 24) + .redacted(reason: .placeholder) + .shimmer(true) + Spacer() + Text("$0.00") + .font(.title3.weight(.medium)) + .foregroundColor(Color(braveSystemName: .textPrimary)) + .redacted(reason: .placeholder) + .shimmer(true) + } else { + MultipleAssetIconsView( + tokens: tokensWithBalances, + iconSize: 24, + maxIconSize: 32 + ) + Spacer() + Text(balance) + .font(.title3.weight(.medium)) + .foregroundColor(Color(braveSystemName: .textPrimary)) + } + } + .padding(contentPadding) + } + + var body: some View { + Button(action: { + action(.viewDetails) + }) { + VStack(spacing: 0) { + topSectionContent() + .background(colorScheme == .dark ? Color.black.opacity(0.5) : Color.white.opacity(0.5)) + + bottomSectionContent + .background( + colorScheme == .dark ? Color.black.opacity(0.4) : Color.clear + ) + } + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(braveSystemName: .dividerInteractive), lineWidth: 1) + .background(cardBackground) + ) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .overlay(alignment: .top) { + topSectionContent(hidingButtons: false) + } + } + + private var cardBackground: some View { + BlockieMaterial(address: account.address) + .blur(radius: 25, opaque: true) + .opacity(0.3) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/Sources/BraveWallet/Crypto/Accounts/Details/AccountDetailsView.swift b/Sources/BraveWallet/Crypto/Accounts/Details/AccountDetailsView.swift index 966d5fc6ae56..32ffabfd0722 100644 --- a/Sources/BraveWallet/Crypto/Accounts/Details/AccountDetailsView.swift +++ b/Sources/BraveWallet/Crypto/Accounts/Details/AccountDetailsView.swift @@ -101,7 +101,6 @@ struct AccountDetailsView: View { } .listStyle(InsetGroupedListStyle()) .listBackgroundColor(Color(UIColor.braveGroupedBackground)) - .navigationTitle(Strings.Wallet.accountDetailsTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .cancellationAction) { diff --git a/Sources/BraveWallet/Crypto/AssetIconView.swift b/Sources/BraveWallet/Crypto/AssetIconView.swift index d91c493e4882..50b8c66f8c1c 100644 --- a/Sources/BraveWallet/Crypto/AssetIconView.swift +++ b/Sources/BraveWallet/Crypto/AssetIconView.swift @@ -8,37 +8,11 @@ import BraveCore import BraveUI import DesignSystem -/// Displays an asset's icon from the token registry -/// -/// By default, creating an `AssetIconView` will result in a dynamically sized icon based -/// on the users size category. If you for some reason need to obtain a fixed size asset icon, -/// wrap this view in another frame of your desired size, for example: -/// -/// AssetIconView(token: .eth) -/// .frame(width: 20, height: 20) -/// -struct AssetIconView: View { - var token: BraveWallet.BlockchainToken - var network: BraveWallet.NetworkInfo - /// If we should show the network logo on non-native assets - var shouldShowNetworkIcon: Bool = false - @ScaledMetric var length: CGFloat = 40 - var maxLength: CGFloat? - @ScaledMetric var networkSymbolLength: CGFloat = 15 - var maxNetworkSymbolLength: CGFloat? - - private var fallbackMonogram: some View { - BlockieMaterial(address: token.contractAddress) - .blur(radius: 8, opaque: true) - .clipShape(Circle()) - .overlay( - Text(token.symbol.first?.uppercased() ?? "") - .font(.system(size: length / 2, weight: .bold, design: .rounded)) - .foregroundColor(.white) - .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) - ) - } - +/// Displays an asset's icon from the token registry or logo. +struct AssetIcon: View { + let token: BraveWallet.BlockchainToken + let network: BraveWallet.NetworkInfo? + var body: some View { Group { if let uiImage = token.localImage(network: network) { @@ -59,13 +33,49 @@ struct AssetIconView: View { fallbackMonogram } } - .frame(width: min(length, maxLength ?? length), height: min(length, maxLength ?? length)) - .overlay(tokenLogo, alignment: .bottomTrailing) - .accessibilityHidden(true) } - @ViewBuilder private var tokenLogo: some View { - if shouldShowNetworkIcon, // explicitly show/not show network logo + @State private var monogramSize: CGSize = .zero + private var fallbackMonogram: some View { + BlockieMaterial(address: token.contractAddress) + .blur(radius: 8, opaque: true) + .clipShape(Circle()) + .readSize(onChange: { newSize in + monogramSize = newSize + }) + .overlay( + Text(token.symbol.first?.uppercased() ?? "") + .font(.system(size: max(monogramSize.width, monogramSize.height) / 2, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) + ) + } +} + +/// Displays an asset's icon from the token registry or logo. +/// +/// By default, creating an `AssetIconView` will result in a dynamically sized icon based +/// on the users size category. +struct AssetIconView: View { + var token: BraveWallet.BlockchainToken + var network: BraveWallet.NetworkInfo? + /// If we should show the network logo on non-native assets. NetworkInfo is required. + var shouldShowNetworkIcon: Bool = false + @ScaledMetric var length: CGFloat = 40 + var maxLength: CGFloat? + @ScaledMetric var networkSymbolLength: CGFloat = 15 + var maxNetworkSymbolLength: CGFloat? + + var body: some View { + AssetIcon(token: token, network: network) + .frame(width: min(length, maxLength ?? length), height: min(length, maxLength ?? length)) + .overlay(tokenNetworkLogo, alignment: .bottomTrailing) + .accessibilityHidden(true) + } + + @ViewBuilder private var tokenNetworkLogo: some View { + if let network, + shouldShowNetworkIcon, // explicitly show/not show network logo (!network.isNativeAsset(token) || network.nativeTokenLogoName != network.networkLogoName), // non-native asset OR if the network is not the official Ethereum network, but uses ETH as gas let image = network.networkLogoImage { Image(uiImage: image) diff --git a/Sources/BraveWallet/Crypto/CryptoTabsView.swift b/Sources/BraveWallet/Crypto/CryptoTabsView.swift index 986017516ecf..4973a9c5d45d 100644 --- a/Sources/BraveWallet/Crypto/CryptoTabsView.swift +++ b/Sources/BraveWallet/Crypto/CryptoTabsView.swift @@ -9,36 +9,38 @@ import SwiftUI import BraveUI import Strings -struct CryptoTabsView: View { - private enum Tab: Equatable, Hashable, CaseIterable { - case portfolio - case activity - case accounts - case market - - @ViewBuilder var tabLabel: some View { - switch self { - case .portfolio: - Label(Strings.Wallet.portfolioPageTitle, braveSystemImage: "leo.coins") - case .activity: - Label(Strings.Wallet.activityPageTitle, braveSystemImage: "leo.activity") - case .accounts: - Label(Strings.Wallet.accountsPageTitle, braveSystemImage: "leo.user.accounts") - case .market: - Label(Strings.Wallet.marketPageTitle, braveSystemImage: "leo.discover") - } +enum CryptoTab: Equatable, Hashable, CaseIterable { + case portfolio + case activity + case accounts + case market + + @ViewBuilder var tabLabel: some View { + switch self { + case .portfolio: + Label(Strings.Wallet.portfolioPageTitle, braveSystemImage: "leo.coins") + case .activity: + Label(Strings.Wallet.activityPageTitle, braveSystemImage: "leo.activity") + case .accounts: + Label(Strings.Wallet.accountsPageTitle, braveSystemImage: "leo.user.accounts") + case .market: + Label(Strings.Wallet.marketPageTitle, braveSystemImage: "leo.discover") } } - +} + +struct CryptoTabsView: View { @ObservedObject var cryptoStore: CryptoStore @ObservedObject var keyringStore: KeyringStore var toolbarDismissContent: DismissContent @State private var isShowingMainMenu: Bool = false - @State private var isTabShowingSettings: [Tab: Bool] = Tab.allCases.reduce(into: [Tab: Bool]()) { $0[$1] = false } + @State private var isTabShowingSettings: [CryptoTab: Bool] = CryptoTab.allCases.reduce(into: [CryptoTab: Bool]()) { $0[$1] = false } @State private var isShowingSearch: Bool = false + @State private var isShowingBackup: Bool = false + @State private var isShowingAddAccount: Bool = false @State private var fetchedPendingRequestsThisSession: Bool = false - @State private var selectedTab: Tab = .portfolio + @State private var selectedTab: CryptoTab = .portfolio private var isConfirmationButtonVisible: Bool { if case .transactions(let txs) = cryptoStore.pendingRequest { @@ -64,9 +66,9 @@ struct CryptoTabsView: View { } .navigationViewStyle(.stack) .tabItem { - Tab.portfolio.tabLabel + CryptoTab.portfolio.tabLabel } - .tag(Tab.portfolio) + .tag(CryptoTab.portfolio) NavigationView { TransactionsActivityView( @@ -81,12 +83,13 @@ struct CryptoTabsView: View { } .navigationViewStyle(.stack) .tabItem { - Tab.activity.tabLabel + CryptoTab.activity.tabLabel } - .tag(Tab.activity) + .tag(CryptoTab.activity) NavigationView { AccountsView( + store: cryptoStore.accountsStore, cryptoStore: cryptoStore, keyringStore: keyringStore ) @@ -98,9 +101,9 @@ struct CryptoTabsView: View { } .navigationViewStyle(.stack) .tabItem { - Tab.accounts.tabLabel + CryptoTab.accounts.tabLabel } - .tag(Tab.accounts) + .tag(CryptoTab.accounts) NavigationView { MarketView( @@ -115,9 +118,9 @@ struct CryptoTabsView: View { } .navigationViewStyle(.stack) .tabItem { - Tab.market.tabLabel + CryptoTab.market.tabLabel } - .tag(Tab.market) + .tag(CryptoTab.market) } .introspectTabBarController(customize: { tabBarController in let appearance = UITabBarAppearance() @@ -169,25 +172,71 @@ struct CryptoTabsView: View { ) .sheet(isPresented: $isShowingMainMenu) { MainMenuView( - isFromPortfolio: selectedTab == .portfolio, + selectedTab: selectedTab, isShowingSettings: Binding(get: { self.isTabShowingSettings[selectedTab, default: false] }, set: { isActive, _ in self.isTabShowingSettings[selectedTab] = isActive }), + isShowingBackup: $isShowingBackup, + isShowingAddAccount: $isShowingAddAccount, keyringStore: keyringStore ) + .background( + Color.clear + .sheet(isPresented: Binding(get: { + isShowingBackup + }, set: { newValue in + if !newValue { + // dismiss menu if we're dismissing backup from menu + isShowingMainMenu = false + } + isShowingBackup = newValue + })) { + NavigationView { + BackupWalletView( + password: nil, + keyringStore: keyringStore + ) + } + .navigationViewStyle(.stack) + .environment(\.modalPresentationMode, $isShowingBackup) + .accentColor(Color(.braveBlurpleTint)) + } + ) + .background( + Color.clear + .sheet(isPresented: Binding(get: { + isShowingAddAccount + }, set: { newValue in + if !newValue { + // dismiss menu if we're dismissing add account from menu + isShowingMainMenu = false + } + isShowingAddAccount = newValue + })) { + NavigationView { + AddAccountView( + keyringStore: keyringStore, + networkStore: cryptoStore.networkStore + ) + } + .navigationViewStyle(StackNavigationViewStyle()) + } + ) } } @ToolbarContentBuilder private var sharedToolbarItems: some ToolbarContent { ToolbarItemGroup(placement: .navigationBarTrailing) { - Button(action: { - cryptoStore.isPresentingAssetSearch = true - }) { - Label(Strings.Wallet.searchTitle, systemImage: "magnifyingglass") - .labelStyle(.iconOnly) - .foregroundColor(Color(.braveBlurpleTint)) + if selectedTab == .portfolio { + Button(action: { + cryptoStore.isPresentingAssetSearch = true + }) { + Label(Strings.Wallet.searchTitle, systemImage: "magnifyingglass") + .labelStyle(.iconOnly) + .foregroundColor(Color(.braveBlurpleTint)) + } } Button(action: { self.isShowingMainMenu = true }) { Label(Strings.Wallet.otherWalletActionsAccessibilityTitle, braveSystemImage: "leo.more.horizontal") @@ -199,7 +248,7 @@ struct CryptoTabsView: View { toolbarDismissContent } - private func settingsNavigationLink(for tab: Tab) -> some View { + private func settingsNavigationLink(for tab: CryptoTab) -> some View { NavigationLink( destination: Web3SettingsView( settingsStore: cryptoStore.settingsStore, diff --git a/Sources/BraveWallet/Crypto/MainMenuView.swift b/Sources/BraveWallet/Crypto/MainMenuView.swift index 01c4b6df6517..4ed49ec10ca1 100644 --- a/Sources/BraveWallet/Crypto/MainMenuView.swift +++ b/Sources/BraveWallet/Crypto/MainMenuView.swift @@ -9,8 +9,10 @@ import Preferences struct MainMenuView: View { - let isFromPortfolio: Bool + let selectedTab: CryptoTab @Binding var isShowingSettings: Bool + @Binding var isShowingBackup: Bool + @Binding var isShowingAddAccount: Bool let keyringStore: KeyringStore @ObservedObject private var isShowingBalances = Preferences.Wallet.isShowingBalances @@ -37,6 +39,16 @@ struct MainMenuView: View { } .frame(height: rowHeight) + Button(action: { + isShowingBackup = true + }) { + MenuRowView( + iconBraveSystemName: "leo.safe", + title: "Back Up Now" + ) + } + .frame(height: rowHeight) + Button(action: { isShowingSettings = true presentationMode.dismiss() @@ -48,9 +60,12 @@ struct MainMenuView: View { } .frame(height: rowHeight) - if isFromPortfolio { + if selectedTab == .portfolio { Divider() portfolioSettings + } else if selectedTab == .accounts { + Divider() + accountsMenuItems } Divider() @@ -124,6 +139,18 @@ struct MainMenuView: View { ) .frame(height: rowHeight) } + + @ViewBuilder private var accountsMenuItems: some View { + Button(action: { + self.isShowingAddAccount = true + }) { + MenuRowView( + iconBraveSystemName: "leo.plus.add", + title: Strings.Wallet.addAccountTitle + ) + } + .frame(height: rowHeight) + } } #if DEBUG @@ -132,8 +159,10 @@ struct MainMenuView_Previews: PreviewProvider { Color.white .sheet(isPresented: .constant(true), content: { MainMenuView( - isFromPortfolio: true, + selectedTab: .portfolio, isShowingSettings: .constant(false), + isShowingBackup: .constant(false), + isShowingAddAccount: .constant(false), keyringStore: .previewStoreWithWalletCreated ) }) diff --git a/Sources/BraveWallet/Crypto/MultipleAssetIconsView.swift b/Sources/BraveWallet/Crypto/MultipleAssetIconsView.swift new file mode 100644 index 000000000000..f64c94bc79c3 --- /dev/null +++ b/Sources/BraveWallet/Crypto/MultipleAssetIconsView.swift @@ -0,0 +1,31 @@ +/* Copyright 2024 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 MultipleAssetIconsView: View { + + let tokens: [BraveWallet.BlockchainToken] + let maxBlockies = 3 + @ScaledMetric var iconSize = 24 + var maxIconSize: CGFloat = 32 + @ScaledMetric var blockieDotSize = 2.0 + + var body: some View { + MultipleCircleIconView( + models: tokens, + shape: .circle, + iconSize: iconSize, + maxIconSize: maxIconSize, + iconDotSize: blockieDotSize + ) { token in + AssetIcon( + token: token, + network: nil // not shown + ) + } + } +} diff --git a/Sources/BraveWallet/Crypto/MultipleNetworkIconsView.swift b/Sources/BraveWallet/Crypto/MultipleNetworkIconsView.swift index b5f7734ae969..ca46dd145746 100644 --- a/Sources/BraveWallet/Crypto/MultipleNetworkIconsView.swift +++ b/Sources/BraveWallet/Crypto/MultipleNetworkIconsView.swift @@ -20,7 +20,7 @@ struct MultipleNetworkIconsView: View { maxIconSize: maxIconSize, iconDotSize: iconDotSize, iconView: { network in - NetworkIcon(network: network, length: iconSize) + NetworkIcon(network: network) } ) } diff --git a/Sources/BraveWallet/Crypto/NetworkSelectionRootView.swift b/Sources/BraveWallet/Crypto/NetworkSelectionRootView.swift index 638d2b100e27..627769fc8186 100644 --- a/Sources/BraveWallet/Crypto/NetworkSelectionRootView.swift +++ b/Sources/BraveWallet/Crypto/NetworkSelectionRootView.swift @@ -161,7 +161,7 @@ private struct NetworkRowView: View { var body: some View { HStack { - NetworkIcon(network: network) + NetworkIconView(network: network) VStack(alignment: .leading, spacing: 0) { Text(network.chainName) .font(.body) diff --git a/Sources/BraveWallet/Crypto/Portfolio/PortfolioView.swift b/Sources/BraveWallet/Crypto/Portfolio/PortfolioView.swift index d295acd4ddae..9991c844250f 100644 --- a/Sources/BraveWallet/Crypto/Portfolio/PortfolioView.swift +++ b/Sources/BraveWallet/Crypto/Portfolio/PortfolioView.swift @@ -99,7 +99,7 @@ struct PortfolioAssetGroupHeaderView: View { VStack(spacing: 0) { HStack { if case let .network(networkInfo) = group.groupType { - NetworkIcon(network: networkInfo, length: 32) + NetworkIconView(network: networkInfo, length: 32) } else if case let .account(accountInfo) = group.groupType { Blockie(address: accountInfo.address) .frame(width: 32, height: 32) diff --git a/Sources/BraveWallet/Crypto/Stores/AccountsStore.swift b/Sources/BraveWallet/Crypto/Stores/AccountsStore.swift new file mode 100644 index 000000000000..ee508957c232 --- /dev/null +++ b/Sources/BraveWallet/Crypto/Stores/AccountsStore.swift @@ -0,0 +1,262 @@ +/* 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 BraveCore +import SwiftUI +import Preferences + +struct AccountDetails: Equatable, Identifiable { + var id: String { account.id } + let account: BraveWallet.AccountInfo + let tokensWithBalance: [BraveWallet.BlockchainToken] + let totalBalanceFiat: String +} + +class AccountsStore: ObservableObject, WalletObserverStore { + + /// Users primary accounts + @Published var primaryAccounts: [AccountDetails] = [] + /// Users imported accounts + @Published var importedAccounts: [AccountDetails] = [] + /// If we are loading prices or balances + @Published var isLoading: Bool = false + /// Users selected currency code + @Published private(set) var currencyCode: String = CurrencyCode.usd.code { + didSet { + currencyFormatter.currencyCode = currencyCode + guard oldValue != currencyCode else { return } + update() + } + } + + let currencyFormatter: NumberFormatter = .usdCurrencyFormatter + + /// Cache of token balances for each account. [account.address: [token.id: balance]] + private var tokenBalancesCache: [String: [String: Double]] = [:] + /// Cache of prices for each token. The key is the token's `assetRatioId`. + private var pricesCache: [String: String] = [:] + + private let keyringService: BraveWalletKeyringService + private let rpcService: BraveWalletJsonRpcService + private let walletService: BraveWalletBraveWalletService + private let assetRatioService: BraveWalletAssetRatioService + private let userAssetManager: WalletUserAssetManagerType + + private var keyringServiceObserver: KeyringServiceObserver? + private var walletServiceObserver: WalletServiceObserver? + + var isObserving: Bool { + keyringServiceObserver != nil && walletServiceObserver != nil + } + + init( + keyringService: BraveWalletKeyringService, + rpcService: BraveWalletJsonRpcService, + walletService: BraveWalletBraveWalletService, + assetRatioService: BraveWalletAssetRatioService, + userAssetManager: WalletUserAssetManagerType + ) { + self.keyringService = keyringService + self.rpcService = rpcService + self.walletService = walletService + self.assetRatioService = assetRatioService + self.userAssetManager = userAssetManager + self.setupObservers() + } + + func setupObservers() { + guard !isObserving else { return } + self.keyringServiceObserver = KeyringServiceObserver( + keyringService: keyringService, + _accountsChanged: { [weak self] in + self?.update() + } + ) + self.walletServiceObserver = WalletServiceObserver( + walletService: walletService, + _onNetworkListChanged: { [weak self] in + self?.update() + } + ) + Preferences.Wallet.showTestNetworks.observe(from: self) + } + + func tearDown() { + keyringServiceObserver = nil + walletServiceObserver = nil + } + + func update() { + Task { @MainActor in + self.isLoading = true + defer { self.isLoading = false } + + let allAccounts = await keyringService.allAccounts() + let allNetworks = await rpcService.allNetworksForSupportedCoins() + let allTokensPerNetwork = userAssetManager.getAllUserAssetsInNetworkAssets( + networks: allNetworks, + includingUserDeleted: false + ).map { networkAssets in // filter out NFTs + NetworkAssets( + network: networkAssets.network, + tokens: networkAssets.tokens.filter { !($0.isNft || $0.isErc721) }, + sortOrder: networkAssets.sortOrder + ) + } + let tokens = allTokensPerNetwork.flatMap(\.tokens) + + var accountDetails = buildAccountDetails(accounts: allAccounts.accounts, tokens: tokens) + self.primaryAccounts = accountDetails + .filter(\.account.isPrimary) + self.importedAccounts = accountDetails + .filter(\.account.isImported) + + await updateBalancesAndPrices( + for: allAccounts.accounts, + networkAssets: allTokensPerNetwork + ) + + accountDetails = buildAccountDetails(accounts: allAccounts.accounts, tokens: tokens) + self.primaryAccounts = accountDetails + .filter(\.account.isPrimary) + self.importedAccounts = accountDetails + .filter(\.account.isImported) + } + } + + @MainActor private func updateBalancesAndPrices( + for accounts: [BraveWallet.AccountInfo], + networkAssets allNetworkAssets: [NetworkAssets] + ) async { + let balancesForAccounts = await withTaskGroup( + of: [String: [String: Double]].self, + body: { group in + for account in accounts { + group.addTask { + let balancesForTokens: [String: Double] = await self.rpcService.fetchBalancesForTokens( + account: account, + networkAssets: allNetworkAssets + ) + return [account.address: balancesForTokens] + } + } + return await group.reduce(into: [String: [String: Double]](), { partialResult, new in + partialResult.merge(with: new) + }) + } + ) + for account in accounts { + if let updatedBalancesForAccount = balancesForAccounts[account.address] { + // if balance fetch failed that we already have cached, don't overwrite existing + if var existing = self.tokenBalancesCache[account.address] { + existing.merge(with: updatedBalancesForAccount) + self.tokenBalancesCache[account.address] = existing + } else { + self.tokenBalancesCache[account.address] = updatedBalancesForAccount + } + } + } + // fetch prices for tokens with balance + var tokensIdsWithBalance: Set = .init() + for accountBalance in tokenBalancesCache.values { + let tokenIdsWithAccountBalance = accountBalance.filter { $1 > 0 }.map(\.key) + tokenIdsWithAccountBalance.forEach { tokensIdsWithBalance.insert($0) } + } + let allTokens = allNetworkAssets.flatMap(\.tokens) + let assetRatioIdsForTokensWithBalance = tokensIdsWithBalance + .compactMap { tokenId in + allTokens.first(where: { $0.id == tokenId })?.assetRatioId + } + let prices: [String: String] = await assetRatioService.fetchPrices( + for: assetRatioIdsForTokensWithBalance, + toAssets: [currencyFormatter.currencyCode], + timeframe: .live + ) + self.pricesCache.merge(with: prices) + } + + // MARK: Helpers + + private func buildAccountDetails( + accounts: [BraveWallet.AccountInfo], + tokens: [BraveWallet.BlockchainToken] + ) -> [AccountDetails] { + accounts + .map { account in + let tokensWithBalance = tokensSortedByFiat( + for: account, + tokens: tokens + ) + let totalBalanceFiat = currencyFormatter.string(from: NSNumber(value: totalBalanceFiat( + for: account, + tokens: tokens + ))) ?? "" + return AccountDetails( + account: account, + tokensWithBalance: tokensWithBalance, + totalBalanceFiat: totalBalanceFiat + ) + } + } + + /// Returns the tokens with a balance for the given account. + private func tokensSortedByFiat( + for account: BraveWallet.AccountInfo, + tokens: [BraveWallet.BlockchainToken] + ) -> [BraveWallet.BlockchainToken] { + guard let tokenBalancesForAccount = tokenBalancesCache[account.address] else { + return [] + } + var tokensFiatForAccount: [(token: BraveWallet.BlockchainToken, fiat: Double)] = [] + for (tokenId, balance) in tokenBalancesForAccount where balance > 0 { + guard let token = tokens.first(where: { $0.id == tokenId }) else { continue } + if let priceString = pricesCache[token.assetRatioId.lowercased()], + let price = Double(priceString) { + let fiat = balance * price + tokensFiatForAccount.append((token, fiat)) + } // else price unknown, can't determine fiat. + } + return tokensFiatForAccount + .sorted(by: { $0.fiat > $1.fiat }) + .map(\.token) + } + + /// Returns the fiat for tokens with a balance for the given account. + private func totalBalanceFiat( + for account: BraveWallet.AccountInfo, + tokens: [BraveWallet.BlockchainToken] + ) -> Double { + guard let accountBalanceCache = tokenBalancesCache[account.address] else { return 0 } + return accountBalanceCache.keys.reduce(0.0) { partialResult, tokenId in + guard let tokenBalanceForAccount = tokenBalanceForAccount(tokenId: tokenId, account: account) else { + // no balances cached for this token + // or no balance cached for this account + return partialResult + } + guard let token = tokens.first(where: { $0.id == tokenId }), + let priceString = pricesCache[token.assetRatioId.lowercased()], + let price = Double(priceString) else { + // price for token unavailable + return partialResult + } + return partialResult + (tokenBalanceForAccount * price) + } + } + + // Helper to get token balance for a given token id and account from cache. + private func tokenBalanceForAccount( + tokenId: String, + account: BraveWallet.AccountInfo + ) -> Double? { + tokenBalancesCache[account.address]?[tokenId] + } +} + +extension AccountsStore: PreferencesObserver { + public func preferencesDidChange(for key: String) { + guard key == Preferences.Wallet.showTestNetworks.key else { return } + update() + } +} diff --git a/Sources/BraveWallet/Crypto/Stores/CryptoStore.swift b/Sources/BraveWallet/Crypto/Stores/CryptoStore.swift index c7c63de891be..c857b79beac6 100644 --- a/Sources/BraveWallet/Crypto/Stores/CryptoStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/CryptoStore.swift @@ -66,6 +66,7 @@ public class CryptoStore: ObservableObject, WalletObserverStore { public let portfolioStore: PortfolioStore let nftStore: NFTStore let transactionsActivityStore: TransactionsActivityStore + let accountsStore: AccountsStore let marketStore: MarketStore @Published var buySendSwapDestination: BuySendSwapDestination? { @@ -194,6 +195,13 @@ public class CryptoStore: ObservableObject, WalletObserverStore { ipfsApi: ipfsApi, userAssetManager: userAssetManager ) + self.accountsStore = .init( + keyringService: keyringService, + rpcService: rpcService, + walletService: walletService, + assetRatioService: assetRatioService, + userAssetManager: userAssetManager + ) self.marketStore = .init( assetRatioService: assetRatioService, blockchainRegistry: blockchainRegistry, diff --git a/Sources/BraveWallet/Crypto/Transaction Confirmations/PendingTransactionView.swift b/Sources/BraveWallet/Crypto/Transaction Confirmations/PendingTransactionView.swift index c5cbb9bdcf3b..53118cb2ce27 100644 --- a/Sources/BraveWallet/Crypto/Transaction Confirmations/PendingTransactionView.swift +++ b/Sources/BraveWallet/Crypto/Transaction Confirmations/PendingTransactionView.swift @@ -165,7 +165,7 @@ struct PendingTransactionView: View { } @ViewBuilder private var editGasFeeButton: some View { - let titleView = Text(Strings.Wallet.editGasFeeButtonTitle) + let titleView = Text(Strings.Wallet.editButtonTitle) .fontWeight(.semibold) .foregroundColor(Color(.braveBlurpleTint)) Group { diff --git a/Sources/BraveWallet/Crypto/Transaction Confirmations/SaferSignTransactionContainerView.swift b/Sources/BraveWallet/Crypto/Transaction Confirmations/SaferSignTransactionContainerView.swift index df8250ebc6e3..8093a07dd67e 100644 --- a/Sources/BraveWallet/Crypto/Transaction Confirmations/SaferSignTransactionContainerView.swift +++ b/Sources/BraveWallet/Crypto/Transaction Confirmations/SaferSignTransactionContainerView.swift @@ -152,7 +152,7 @@ struct SaferSignTransactionContainerView: View { Text(gasFee?.fiat ?? "") .foregroundColor(Color(.braveLabel)) Button(action: editGasFeeTapped) { - Text(Strings.Wallet.editGasFeeButtonTitle) + Text(Strings.Wallet.editButtonTitle) .fontWeight(.semibold) .foregroundColor(Color(.braveBlurpleTint)) } diff --git a/Sources/BraveWallet/Extensions/BraveWalletExtensions.swift b/Sources/BraveWallet/Extensions/BraveWalletExtensions.swift index 9e6a050fd293..d644bd2db262 100644 --- a/Sources/BraveWallet/Extensions/BraveWalletExtensions.swift +++ b/Sources/BraveWallet/Extensions/BraveWalletExtensions.swift @@ -141,6 +141,24 @@ extension BraveWallet.AccountId { } } +extension BraveWallet.AccountInfo { + /// String to display what this account supports, ex. `"Ethereum + EVM Chains"`. + var accountSupportDisplayString: String { + switch coin { + case .eth: + return Strings.Wallet.ethAccountDescription + case .sol: + return Strings.Wallet.solAccountDescription + case .fil: + return Strings.Wallet.filAccountDescription + case .btc, .zec: + return "" + @unknown default: + return "" + } + } +} + extension BraveWallet.CoinType { public var keyringIds: [BraveWallet.KeyringId] { switch self { @@ -374,8 +392,9 @@ extension BraveWallet.BlockchainToken { } /// Returns the local image asset for the `BlockchainToken`. - func localImage(network: BraveWallet.NetworkInfo) -> UIImage? { - if network.isNativeAsset(self), let uiImage = network.nativeTokenLogoImage { + func localImage(network: BraveWallet.NetworkInfo?) -> UIImage? { + if let network, + network.isNativeAsset(self), let uiImage = network.nativeTokenLogoImage { return uiImage } diff --git a/Sources/BraveWallet/MultipleCircleIconView.swift b/Sources/BraveWallet/MultipleCircleIconView.swift index 6e9b6ff30c1e..489ba68a4759 100644 --- a/Sources/BraveWallet/MultipleCircleIconView.swift +++ b/Sources/BraveWallet/MultipleCircleIconView.swift @@ -42,35 +42,51 @@ struct MultipleCircleIconView: View { HStack(spacing: -(min(iconSize, maxIconSize) / 2)) { let numberOfIcons = min(maxIcons, models.count) ForEach(0.. maxIcons { - Group { - if shape == .circle { - Circle() - } else { - RoundedRectangle(cornerRadius: 4) + buildShapeBorder { + Group { + if shape == .circle { + Circle() + } else { + RoundedRectangle(cornerRadius: 4) + } } + .foregroundColor(Color(.braveBlurpleTint)) + .frame(width: min(iconSize, maxIconSize), height: min(iconSize, maxIconSize)) + .overlay( + HStack(spacing: 1) { + ContainerRelativeShape() + .frame(width: iconDotSize, height: iconDotSize) + ContainerRelativeShape() + .frame(width: iconDotSize, height: iconDotSize) + ContainerRelativeShape() + .frame(width: iconDotSize, height: iconDotSize) + } + .foregroundColor(.white) + .containerShape(ContainerShape(shape: shape)) + ) } - .foregroundColor(Color(.braveBlurpleTint)) - .frame(width: min(iconSize, maxIconSize), height: min(iconSize, maxIconSize)) - .overlay( - HStack(spacing: 1) { - ContainerRelativeShape() - .frame(width: iconDotSize, height: iconDotSize) - ContainerRelativeShape() - .frame(width: iconDotSize, height: iconDotSize) - ContainerRelativeShape() - .frame(width: iconDotSize, height: iconDotSize) - } - .foregroundColor(.white) - ) } } } + + /// Creates the ContainerRelativeShape with `content` overlayed, inset to show a border. + private func buildShapeBorder(content: () -> some View) -> some View { + Color(.secondaryBraveGroupedBackground) + .frame(width: min(iconSize, maxIconSize), height: min(iconSize, maxIconSize)) + .clipShape(ContainerRelativeShape()) + .overlay { + content() + .frame(width: min(iconSize, maxIconSize) - 2, height: min(iconSize, maxIconSize) - 2) + .clipShape(ContainerRelativeShape()) + } + .containerShape(ContainerShape(shape: shape)) + } } diff --git a/Sources/BraveWallet/NetworkIcon.swift b/Sources/BraveWallet/NetworkIcon.swift index 4492b31f560b..0a7d4757ee28 100644 --- a/Sources/BraveWallet/NetworkIcon.swift +++ b/Sources/BraveWallet/NetworkIcon.swift @@ -9,9 +9,7 @@ import BraveUI struct NetworkIcon: View { - var network: BraveWallet.NetworkInfo - - @ScaledMetric var length: CGFloat = 30 + let network: BraveWallet.NetworkInfo var body: some View { Group { @@ -36,14 +34,17 @@ struct NetworkIcon: View { } } .aspectRatio(1, contentMode: .fit) - .frame(width: length, height: length) } + @State private var monogramSize: CGSize = .zero private var networkIconMonogram: some View { Blockie(address: network.chainName, shape: .circle) + .readSize(onChange: { newSize in + monogramSize = newSize + }) .overlay( Text(network.chainName.first?.uppercased() ?? "") - .font(.system(size: length / 2, weight: .bold, design: .rounded)) + .font(.system(size: max(monogramSize.width, monogramSize.height) / 2, weight: .bold, design: .rounded)) .foregroundColor(.white) .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) ) @@ -58,3 +59,18 @@ struct NetworkIcon: View { return nil } } + +struct NetworkIconView: View { + + let network: BraveWallet.NetworkInfo + @ScaledMetric var length: CGFloat = 30 + var maxLength: CGFloat? + + var body: some View { + NetworkIcon(network: network) + .frame( + width: min(length, maxLength ?? length), + height: min(length, maxLength ?? length) + ) + } +} diff --git a/Sources/BraveWallet/Preview Content/MockStores.swift b/Sources/BraveWallet/Preview Content/MockStores.swift index 947387988029..1ebe61710210 100644 --- a/Sources/BraveWallet/Preview Content/MockStores.swift +++ b/Sources/BraveWallet/Preview Content/MockStores.swift @@ -255,6 +255,18 @@ extension TransactionsActivityStore { ) } +extension AccountsStore { + static var previewStore: AccountsStore { + .init( + keyringService: MockKeyringService(), + rpcService: MockJsonRpcService(), + walletService: MockBraveWalletService(), + assetRatioService: MockAssetRatioService(), + userAssetManager: TestableWalletUserAssetManager() + ) + } +} + extension BraveWallet.TestSolanaTxManagerProxy { static var previewProxy: BraveWallet.TestSolanaTxManagerProxy { let solTxManagerProxy = BraveWallet.TestSolanaTxManagerProxy() diff --git a/Sources/BraveWallet/WalletStrings.swift b/Sources/BraveWallet/WalletStrings.swift index 9f9e3815368d..144e05605430 100644 --- a/Sources/BraveWallet/WalletStrings.swift +++ b/Sources/BraveWallet/WalletStrings.swift @@ -400,18 +400,11 @@ extension Strings { value: "Import…", comment: "A button title that when tapped will display a file import dialog" ) - public static let primaryCryptoAccountsTitle = NSLocalizedString( - "wallet.primaryCryptoAccountsTitle", + public static let importedCryptoAccountsTitle = NSLocalizedString( + "wallet.importedCryptoAccountsTitle", tableName: "BraveWallet", bundle: .module, - value: "Primary Crypto Accounts", - comment: "A title above a list of crypto accounts that are not imported" - ) - public static let secondaryCryptoAccountsTitle = NSLocalizedString( - "wallet.secondaryCryptoAccountsTitle", - tableName: "BraveWallet", - bundle: .module, - value: "Secondary Accounts", + value: "Imported Accounts", comment: "A title above a list of crypto accounts that are imported" ) public static let secondaryCryptoAccountsSubtitle = NSLocalizedString( @@ -428,6 +421,34 @@ extension Strings { value: "No secondary accounts.", comment: "The empty state shown when you have no imported accounts" ) + public static let ethAccountDescription = NSLocalizedString( + "wallet.ethAccountDescription", + tableName: "BraveWallet", + bundle: .module, + value: "Ethereum + EVM Chains", + comment: "A description of an Ethereum account, displayed in Accounts tab." + ) + public static let solAccountDescription = NSLocalizedString( + "wallet.solAccountDescription", + tableName: "BraveWallet", + bundle: .module, + value: "Solana + SVM Chains", + comment: "A description of an Solana account, displayed in Accounts tab." + ) + public static let filAccountDescription = NSLocalizedString( + "wallet.filAccountDescription", + tableName: "BraveWallet", + bundle: .module, + value: "Filecoin", + comment: "A description of an Filecoin account, displayed in Accounts tab." + ) + public static let exportButtonTitle = NSLocalizedString( + "wallet.exportButtonTitle", + tableName: "BraveWallet", + bundle: .module, + value: "Export", + comment: "A button title displayed in a menu to export an account's private key." + ) public static let incorrectPasswordErrorMessage = NSLocalizedString( "wallet.incorrectPasswordErrorMessage", tableName: "BraveWallet", @@ -1381,12 +1402,12 @@ extension Strings { value: "Transaction Fee", comment: "A title displayed beside a number describing the cost of the transaction in SOL for any Solana transaction" ) - public static let editGasFeeButtonTitle = NSLocalizedString( - "wallet.editGasFeeButtonTitle", + public static let editButtonTitle = NSLocalizedString( + "wallet.editButtonTitle", tableName: "BraveWallet", bundle: .module, value: "Edit", - comment: "A button title displayed under a Gas Fee title that allows the user to adjust the gas fee/transactions priority" + comment: "A button title displayed under a Gas Fee title that allows the user to adjust the gas fee/transactions priority, on accounts tab for editing an account name, etc." ) public static let total = NSLocalizedString( "wallet.total", diff --git a/Sources/DesignSystem/Icons/Symbols.xcassets/leo.web3.blockexplorer.symbolset/Contents.json b/Sources/DesignSystem/Icons/Symbols.xcassets/leo.web3.blockexplorer.symbolset/Contents.json new file mode 100644 index 000000000000..2f415ce6e4c0 --- /dev/null +++ b/Sources/DesignSystem/Icons/Symbols.xcassets/leo.web3.blockexplorer.symbolset/Contents.json @@ -0,0 +1,11 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "idiom" : "universal" + } + ] +} diff --git a/Tests/BraveWalletTests/AccountsStoreTests.swift b/Tests/BraveWalletTests/AccountsStoreTests.swift new file mode 100644 index 000000000000..8e47eb8da54e --- /dev/null +++ b/Tests/BraveWalletTests/AccountsStoreTests.swift @@ -0,0 +1,245 @@ +// Copyright 2024 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 Combine +import XCTest +import BraveCore +import Preferences +@testable import BraveWallet + +@MainActor class AccountsStoreTests: XCTestCase { + + private var cancellables: Set = .init() + + 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 solAccount1: BraveWallet.AccountInfo = .mockSolAccount + let filAccount1: BraveWallet.AccountInfo = .mockFilAccount + let filTestnetAccount: BraveWallet.AccountInfo = .mockFilTestnetAccount + + let mockETHBalanceAccount1: Double = 0.896 + let mockETHPrice: String = "3059.99" + lazy var mockETHAssetPrice: BraveWallet.AssetPrice = .init( + fromAsset: "eth", toAsset: "usd", + price: mockETHPrice, assetTimeframeChange: "-57.23" + ) + let mockUSDCBalanceAccount1: Double = 0.03 + let mockUSDCBalanceAccount2: Double = 0.01 + let mockUSDCPrice: String = "1" + lazy var mockUSDCAssetPrice: BraveWallet.AssetPrice = .init( + fromAsset: BraveWallet.BlockchainToken.mockUSDCToken.assetRatioId, + toAsset: "usd", price: mockUSDCPrice, assetTimeframeChange: "-57.23" + ) + let ethMainnetTokens: [BraveWallet.BlockchainToken] = [ + BraveWallet.NetworkInfo.mockMainnet.nativeToken, + .mockUSDCToken.copy(asVisibleAsset: true) + ] + + let mockSOLBalance: Double = 3.8765 + let mockSOLPrice: String = "200" + lazy var mockSOLAssetPrice: BraveWallet.AssetPrice = .init( + fromAsset: "sol", toAsset: "usd", + price: mockSOLPrice, assetTimeframeChange: "-57.23" + ) + let solMainnetTokens: [BraveWallet.BlockchainToken] = [ + BraveWallet.NetworkInfo.mockSolana.nativeToken + ] + + let mockFILBalanceAccount1: Double = 1 + let mockFILTestnetBalanceAccount1: Double = 10 + let mockFILPrice: String = "4.00" + lazy var mockFILAssetPrice: BraveWallet.AssetPrice = .init( + fromAsset: "fil", toAsset: "usd", + price: mockFILPrice, assetTimeframeChange: "-57.23" + ) + let filMainnetTokens: [BraveWallet.BlockchainToken] = [ + BraveWallet.NetworkInfo.mockFilecoinMainnet.nativeToken + ] + let filTestnetTokens: [BraveWallet.BlockchainToken] = [ + BraveWallet.NetworkInfo.mockFilecoinTestnet.nativeToken + ] + + let formatter = WeiFormatter(decimalFormatStyle: .decimals(precision: 18)) + + func testUpdate() async { + Preferences.Wallet.showTestNetworks.value = true + let ethBalanceWei = formatter.weiString( + from: mockETHBalanceAccount1, + radix: .hex, + decimals: Int(BraveWallet.NetworkInfo.mockMainnet.nativeToken.decimals) + ) ?? "" + 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) + ) ?? "" + let mockSOLLamportBalance: UInt64 = 3876535000 // ~3.8765 SOL + let mockFilBalanceInWei = formatter.weiString( + from: mockFILBalanceAccount1, + radix: .decimal, + decimals: Int(BraveWallet.NetworkInfo.mockFilecoinMainnet.nativeToken.decimals) + ) ?? "" + let mockFilTestnetBalanceInWei = formatter.weiString( + from: mockFILBalanceAccount1, + radix: .decimal, + decimals: Int(BraveWallet.NetworkInfo.mockFilecoinTestnet.nativeToken.decimals) + ) ?? "" + + let keyringService = BraveWallet.TestKeyringService() + keyringService._addObserver = { _ in } + keyringService._allAccounts = { + $0(.init( + accounts: [ + self.ethAccount1, self.ethAccount2, + self.solAccount1, self.filAccount1, + self.filTestnetAccount + ], + selectedAccount: self.ethAccount1, + ethDappSelectedAccount: self.ethAccount1, + solDappSelectedAccount: self.solAccount1 + )) + } + let rpcService = BraveWallet.TestJsonRpcService() + rpcService._addObserver = { _ in } + rpcService._allNetworks = { coin, completion in + completion([ + .mockMainnet, + .mockSolana, + .mockFilecoinMainnet, + .mockFilecoinTestnet + ].filter { $0.coin == coin }) + } + rpcService._balance = { accountAddress, coin, chainId, completion in + if coin == .eth, + chainId == BraveWallet.MainnetChainId, + accountAddress == self.ethAccount1.address { + completion(ethBalanceWei, .success, "") + } else if coin == .fil, + chainId == BraveWallet.FilecoinMainnet, + accountAddress == self.filAccount1.address { // .fil + completion(mockFilBalanceInWei, .success, "") + } else if coin == .fil, + chainId == BraveWallet.FilecoinTestnet, + accountAddress == self.filTestnetAccount.address { + completion(mockFilTestnetBalanceInWei, .success, "") + } else { + completion("", .internalError, "") + } + } + rpcService._erc20TokenBalance = { contractAddress, accountAddress, _, completion in + // usdc balance + if accountAddress == self.ethAccount1.address { + completion(usdcAccount1BalanceWei, .success, "") + } else if accountAddress == self.ethAccount2.address { + completion(usdcAccount2BalanceWei, .success, "") + } + } + rpcService._erc721TokenBalance = { _, _, _, _, completion in + completion("", .internalError, "") + } + rpcService._solanaBalance = { accountAddress, chainId, completion in + if accountAddress == self.solAccount1.address { + completion(mockSOLLamportBalance, .success, "") + } else { + completion(0, .success, "") + } + } + + let walletService = BraveWallet.TestBraveWalletService() + walletService._addObserver = { _ in } + + let assetRatioService = BraveWallet.TestAssetRatioService() + assetRatioService._price = { priceIds, _, _, completion in + completion(true, [self.mockETHAssetPrice, + self.mockUSDCAssetPrice, + self.mockSOLAssetPrice, + self.mockFILAssetPrice]) + } + + let userAssetManager = TestableWalletUserAssetManager() + userAssetManager._getAllUserAssetsInNetworkAssets = { networks, _ in + [ + NetworkAssets( + network: .mockMainnet, + tokens: self.ethMainnetTokens, + sortOrder: 0), + NetworkAssets( + network: .mockSolana, + tokens: self.solMainnetTokens, + sortOrder: 1), + NetworkAssets( + network: .mockFilecoinMainnet, + tokens: self.filMainnetTokens, + sortOrder: 2), + NetworkAssets( + network: .mockFilecoinTestnet, + tokens: self.filTestnetTokens, + sortOrder: 3) + ].filter { networkAsset in + networks.contains(where: { $0.chainId == networkAsset.network.chainId }) + } + } + + let store = AccountsStore( + keyringService: keyringService, + rpcService: rpcService, + walletService: walletService, + assetRatioService: assetRatioService, + userAssetManager: userAssetManager + ) + + let updateExpectation = expectation(description: "update") + store.$primaryAccounts + .dropFirst() // initial + .collect(2) // with accounts & tokens, with balances & prices loaded + .sink { accountDetails in + defer { updateExpectation.fulfill() } + guard let accountDetails = accountDetails.last else { + XCTFail("Expected account details models") + return + } + XCTAssertEqual(accountDetails.count, 5) + + XCTAssertEqual(accountDetails[safe: 0]?.account, self.ethAccount1) + XCTAssertEqual(accountDetails[safe: 0]?.tokensWithBalance, self.ethMainnetTokens) + XCTAssertEqual(accountDetails[safe: 0]?.totalBalanceFiat, "$2,741.78") + + XCTAssertEqual(accountDetails[safe: 1]?.account, self.ethAccount2) + XCTAssertEqual(accountDetails[safe: 1]?.tokensWithBalance.count, 1) // usdc only + XCTAssertEqual(accountDetails[safe: 1]?.tokensWithBalance[safe: 0], + self.ethMainnetTokens[safe: 1]) // usdc + XCTAssertEqual(accountDetails[safe: 1]?.totalBalanceFiat, "$0.01") + + XCTAssertEqual(accountDetails[safe: 2]?.account, self.solAccount1) + XCTAssertEqual(accountDetails[safe: 2]?.tokensWithBalance, self.solMainnetTokens) + XCTAssertEqual(accountDetails[safe: 2]?.totalBalanceFiat, "$775.30") + + XCTAssertEqual(accountDetails[safe: 3]?.account, self.filAccount1) + XCTAssertEqual(accountDetails[safe: 3]?.tokensWithBalance, self.filMainnetTokens) + XCTAssertEqual(accountDetails[safe: 3]?.totalBalanceFiat, "$4.00") + + XCTAssertEqual(accountDetails[safe: 4]?.account, self.filTestnetAccount) + XCTAssertEqual(accountDetails[safe: 4]?.tokensWithBalance, self.filTestnetTokens) + XCTAssertEqual(accountDetails[safe: 4]?.totalBalanceFiat, "$4.00") + }.store(in: &cancellables) + + store.update() + + await fulfillment(of: [updateExpectation], timeout: 1) + } + + override class func tearDown() { + super.tearDown() + Preferences.Wallet.showTestNetworks.reset() + } +}