From abf245f5b0eeb25c4a0f00df743786f7bfba7792 Mon Sep 17 00:00:00 2001 From: StephenHeaps <5314553+StephenHeaps@users.noreply.github.com> Date: Tue, 6 Feb 2024 11:16:47 -0500 Subject: [PATCH] Fix brave/brave-ios#8663: Account Details v2 (brave/brave-ios#8716) * Account Activity / Details v2 WIP * Update unit tests for transaction sections, assets sorted by fiat * Add security row for dapp supported accounts * Fix cases where currency formatter's `maximumFractionDigits` was not restored in `TransactionParser` (`solEstimatedTxFee` unavailable or `filTxData` unavailable) * Remove old observer extensions from `AccountActivityStore`. * Fix for NFTs not being displayed correctly in transactions list (for NFT transactions) * Update copy for account details rows --- .../Accounts/AccountTransactionListView.swift | 47 +- .../Crypto/Accounts/AccountsView.swift | 25 +- .../Activity/AccountActivityView.swift | 501 +++++++++++------- .../BraveWallet/Crypto/AssetsListView.swift | 58 ++ Sources/BraveWallet/Crypto/CryptoView.swift | 1 - Sources/BraveWallet/Crypto/NFTsGridView.swift | 117 ++++ .../Crypto/Stores/AccountActivityStore.swift | 479 ++++++++--------- .../Transactions/TransactionParser.swift | 8 +- .../Transactions/TransactionsListView.swift | 44 -- Sources/BraveWallet/SearchBar.swift | 50 ++ Sources/BraveWallet/WalletStrings.swift | 56 ++ .../leo.grid04.symbolset/Contents.json | 11 + .../leo.lock.dots.symbolset/Contents.json | 11 + .../AccountActivityStoreTests.swift | 157 ++++-- 14 files changed, 975 insertions(+), 590 deletions(-) create mode 100644 Sources/BraveWallet/Crypto/AssetsListView.swift create mode 100644 Sources/BraveWallet/Crypto/NFTsGridView.swift create mode 100644 Sources/BraveWallet/SearchBar.swift create mode 100644 Sources/DesignSystem/Icons/Symbols.xcassets/leo.grid04.symbolset/Contents.json create mode 100644 Sources/DesignSystem/Icons/Symbols.xcassets/leo.lock.dots.symbolset/Contents.json diff --git a/Sources/BraveWallet/Crypto/Accounts/AccountTransactionListView.swift b/Sources/BraveWallet/Crypto/Accounts/AccountTransactionListView.swift index 60dfb77c4318..5580ca67ee32 100644 --- a/Sources/BraveWallet/Crypto/Accounts/AccountTransactionListView.swift +++ b/Sources/BraveWallet/Crypto/Accounts/AccountTransactionListView.swift @@ -9,13 +9,11 @@ import struct Shared.Strings import BraveUI struct AccountTransactionListView: View { - @ObservedObject var keyringStore: KeyringStore @ObservedObject var activityStore: AccountActivityStore @ObservedObject var networkStore: NetworkStore - @Environment(\.openURL) private var openWalletURL - @State private var transactionDetails: TransactionDetailsStore? + @State private var query: String = "" private func emptyTextView(_ message: String) -> some View { Text(message) @@ -26,40 +24,15 @@ struct AccountTransactionListView: View { } var body: some View { - List { - Section( - header: WalletListHeaderView(title: Text(Strings.Wallet.transactionsTitle)) - ) { - Group { - if activityStore.transactionSummaries.isEmpty { - emptyTextView(Strings.Wallet.noTransactions) - } else { - ForEach(activityStore.transactionSummaries) { txSummary in - Button(action: { - self.transactionDetails = activityStore.transactionDetailsStore(for: txSummary.txInfo) - }) { - TransactionSummaryView(summary: txSummary) - } - .contextMenu { - if !txSummary.txHash.isEmpty { - Button(action: { - if let txNetwork = self.networkStore.allChains.first(where: { $0.chainId == txSummary.txInfo.chainId }), - let url = txNetwork.txBlockExplorerLink(txHash: txSummary.txHash, for: txNetwork.coin) { - openWalletURL(url) - } - }) { - Label(Strings.Wallet.viewOnBlockExplorer, systemImage: "arrow.up.forward.square") - } - } - } - } - } - } - .listRowBackground(Color(.secondaryBraveGroupedBackground)) + TransactionsListView( + transactionSections: activityStore.transactionSections, + query: $query, + showFilter: false, + filtersButtonTapped: { }, + transactionTapped: { transaction in + self.transactionDetails = activityStore.transactionDetailsStore(for: transaction) } - } - .listStyle(InsetGroupedListStyle()) - .listBackgroundColor(Color(UIColor.braveGroupedBackground)) + ) .navigationTitle(Strings.Wallet.transactionsTitle) .navigationBarTitleDisplayMode(.inline) .sheet( @@ -82,10 +55,8 @@ struct AccountTransactionListView: View { struct AccountTransactionListView_Previews: PreviewProvider { static var previews: some View { AccountTransactionListView( - keyringStore: .previewStore, activityStore: { let store: AccountActivityStore = .previewStore - store.previewTransactions() return store }(), networkStore: .previewStore diff --git a/Sources/BraveWallet/Crypto/Accounts/AccountsView.swift b/Sources/BraveWallet/Crypto/Accounts/AccountsView.swift index a72540d5f59e..2a2977b63302 100644 --- a/Sources/BraveWallet/Crypto/Accounts/AccountsView.swift +++ b/Sources/BraveWallet/Crypto/Accounts/AccountsView.swift @@ -18,9 +18,12 @@ struct AccountsView: View { @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? - + /// When populated, private key export is presented modally for the given account. @State private var selectedAccountForExport: BraveWallet.AccountInfo? + @Environment(\.buySendSwapDestination) + private var buySendSwapDestination: Binding + var body: some View { ScrollView { LazyVStack(spacing: 16) { @@ -63,25 +66,31 @@ struct AccountsView: View { } .navigationTitle(Strings.Wallet.accountsPageTitle) .navigationBarTitleDisplayMode(.inline) + .background(Color(braveSystemName: .containerBackground)) .background( NavigationLink( isActive: Binding( get: { selectedAccountActivity != nil }, - set: { if !$0 { selectedAccountActivity = nil } } + set: { + if !$0 { + selectedAccountActivity = nil + if let selectedAccountActivity { + cryptoStore.closeAccountActivityStore(for: selectedAccountActivity) + } + } + } ), destination: { if let account = selectedAccountActivity { AccountActivityView( - keyringStore: keyringStore, - activityStore: cryptoStore.accountActivityStore( + store: cryptoStore.accountActivityStore( for: account, observeAccountUpdates: false ), - networkStore: cryptoStore.networkStore + cryptoStore: cryptoStore, + keyringStore: keyringStore, + buySendSwapDestination: buySendSwapDestination ) - .onDisappear { - cryptoStore.closeAccountActivityStore(for: account) - } } }, label: { diff --git a/Sources/BraveWallet/Crypto/Accounts/Activity/AccountActivityView.swift b/Sources/BraveWallet/Crypto/Accounts/Activity/AccountActivityView.swift index e05297ff9672..4f238994bb2d 100644 --- a/Sources/BraveWallet/Crypto/Accounts/Activity/AccountActivityView.swift +++ b/Sources/BraveWallet/Crypto/Accounts/Activity/AccountActivityView.swift @@ -11,229 +11,362 @@ import DesignSystem import BraveUI struct AccountActivityView: View { - @ObservedObject var keyringStore: KeyringStore - @ObservedObject var activityStore: AccountActivityStore - @ObservedObject var networkStore: NetworkStore - - @State private var detailsPresentation: DetailsPresentation? - @State private var transactionDetails: TransactionDetailsStore? - @Environment(\.presentationMode) @Binding private var presentationMode - @Environment(\.openURL) private var openWalletURL - - private struct DetailsPresentation: Identifiable { - var inEditMode: Bool - var id: String { - "\(inEditMode)" - } - } - - private var accountInfo: BraveWallet.AccountInfo { - guard let info = keyringStore.allAccounts.first(where: { $0.address == activityStore.account.address }) else { - // The account has been removed... User should technically never see this state because - // `AccountsViewController` pops this view off the stack when the account is removed - presentationMode.dismiss() - return activityStore.account - } - return info - } - - private func emptyTextView(_ message: String) -> some View { - Text(message) - .font(.footnote.weight(.medium)) - .frame(maxWidth: .infinity) - .multilineTextAlignment(.center) - .foregroundColor(Color(.secondaryBraveLabel)) - } - + @ObservedObject var store: AccountActivityStore + var cryptoStore: CryptoStore + var keyringStore: KeyringStore + @Binding var buySendSwapDestination: BuySendSwapDestination? + + @State private var didLoad: Bool = false + @State private var isPresentingEditAccount: Bool = false + @State private var isPresentingExportAccount: Bool = false + var body: some View { - List { - Section { - AccountActivityHeaderView(account: accountInfo) { editMode in - // Needed to use an identifiable object here instead of two @State Bool's due to a SwiftUI bug - detailsPresentation = .init(inEditMode: editMode) - } - .frame(maxWidth: .infinity) - .listRowInsets(.zero) - .listRowBackground(Color(.braveGroupedBackground)) + ScrollView { + VStack(spacing: 0) { + headerSection + + rowsSection } - Section( - header: WalletListHeaderView(title: Text(Strings.Wallet.assetsTitle)) - ) { - Group { - if activityStore.userAssets.isEmpty { - emptyTextView(Strings.Wallet.noAssets) - } else { - ForEach(activityStore.userAssets) { asset in - PortfolioAssetView( - image: AssetIconView( - token: asset.token, - network: asset.network, - shouldShowNetworkIcon: true - ), - title: asset.token.name, - symbol: asset.token.symbol, - networkName: asset.network.chainName, - amount: asset.fiatAmount(currencyFormatter: activityStore.currencyFormatter), - quantity: asset.quantity - ) - } + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(store.account.name) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Menu(content: { + Button(action: { + isPresentingEditAccount = true + }) { + Label(Strings.Wallet.editButtonTitle, braveSystemImage: "leo.edit.pencil") } - } - .listRowBackground(Color(.secondaryBraveGroupedBackground)) - } - if !activityStore.userNFTs.isEmpty { - Section(content: { - Group { - ForEach(activityStore.userNFTs) { nftAsset in - NFTAssetView( - image: NFTIconView( - token: nftAsset.token, - network: nftAsset.network, - url: nftAsset.nftMetadata?.imageURL, - shouldShowNetworkIcon: true - ), - title: nftAsset.token.nftTokenTitle, - symbol: nftAsset.token.symbol, - networkName: nftAsset.network.chainName, - quantity: "\(nftAsset.balanceForAccounts[activityStore.account.address] ?? 0)" - ) - } + Button(action: { + isPresentingExportAccount = true + }) { + Label(Strings.Wallet.exportButtonTitle, braveSystemImage: "leo.key") } - .listRowBackground(Color(.secondaryBraveGroupedBackground)) - }, header: { - WalletListHeaderView(title: Text(Strings.Wallet.nftsTitle)) + }, label: { + Image(braveSystemName: "leo.more.horizontal") + .foregroundColor(Color(braveSystemName: .iconInteractive)) }) } - Section( - header: WalletListHeaderView(title: Text(Strings.Wallet.transactionsTitle)) - ) { - Group { - if activityStore.transactionSummaries.isEmpty { - emptyTextView(Strings.Wallet.noTransactions) - } else { - ForEach(activityStore.transactionSummaries) { txSummary in - Button(action: { - self.transactionDetails = activityStore.transactionDetailsStore(for: txSummary.txInfo) - }) { - TransactionSummaryView(summary: txSummary) - } - .contextMenu { - if !txSummary.txHash.isEmpty { - Button(action: { - if let txNetwork = self.networkStore.allChains.first(where: { $0.chainId == txSummary.txInfo.chainId }), - let url = txNetwork.txBlockExplorerLink(txHash: txSummary.txHash, for: txNetwork.coin) { - openWalletURL(url) - } - }) { - Label(Strings.Wallet.viewOnBlockExplorer, systemImage: "arrow.up.forward.square") - } - } - } - } - } - } - .listRowBackground(Color(.secondaryBraveGroupedBackground)) - } } - .listStyle(InsetGroupedListStyle()) - .listBackgroundColor(Color(UIColor.braveGroupedBackground)) + .background( + VStack(spacing: 0) { + Color(braveSystemName: .containerBackground) + .frame(maxHeight: 200) + .edgesIgnoringSafeArea(.top) + Color(braveSystemName: .pageBackground) + .edgesIgnoringSafeArea(.all) + } + ) .background( Color.clear - .sheet(item: $detailsPresentation) { + .sheet(isPresented: $isPresentingEditAccount) { AccountDetailsView( keyringStore: keyringStore, - account: accountInfo, - editMode: $0.inEditMode + account: store.account, + editMode: true ) } ) .background( Color.clear - .sheet( - isPresented: Binding( - get: { self.transactionDetails != nil }, - set: { - if !$0 { - self.transactionDetails = nil - self.activityStore.closeTransactionDetailsStore() + .sheet(isPresented: $isPresentingExportAccount) { + NavigationView { + AccountPrivateKeyView( + keyringStore: keyringStore, + account: store.account + ) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { + isPresentingExportAccount = false + }) { + Text(Strings.cancelButtonTitle) + .foregroundColor(Color(.braveBlurpleTint)) + } } } - ) - ) { - if let transactionDetailsStore = transactionDetails { - TransactionDetailsView( - transactionDetailsStore: transactionDetailsStore, - networkStore: networkStore - ) } } ) - .onReceive(keyringStore.$allAccounts) { allAccounts in - if !allAccounts.contains(where: { $0.address == accountInfo.address }) { - // Account was deleted - detailsPresentation = nil - presentationMode.dismiss() - } - } .onAppear { - activityStore.update() + // Skip reload when popping detail view off stack (assets, nfts, transactions) + guard !didLoad else { return } + didLoad = true + store.update() } } -} - -private struct AccountActivityHeaderView: View { - var account: BraveWallet.AccountInfo - var action: (_ tappedEdit: Bool) -> Void - - var body: some View { - VStack { - Blockie(address: account.address) - .frame(width: 64, height: 64) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .accessibilityHidden(true) - VStack(spacing: 4) { - Text(account.name) - .fontWeight(.semibold) - AddressView(address: account.address) { - Text(account.address.truncatedAddress) - .font(.footnote) + + private var headerSection: some View { + VStack(spacing: 0) { + VStack(spacing: 8) { + Blockie(address: store.account.address) + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .accessibilityHidden(true) + VStack(spacing: 0) { + Text(store.account.name) + .font(.title2.weight(.semibold)) + AddressView(address: store.account.address) { + Text(store.account.address.truncatedAddress) + .font(.caption) + } } } - .padding(.bottom, 12) - HStack { - Button(action: { action(false) }) { - HStack { - Image(braveSystemName: "leo.qr.code") - .font(.body) - Text(Strings.Wallet.detailsButtonTitle) - .font(.footnote.weight(.bold)) - } + + Spacer().frame(height: 16) + + VStack { + if store.isLoadingAccountFiat { + Text(store.accountTotalFiat) + .font(.title.weight(.medium)) + .redacted(reason: .placeholder) + .shimmer(store.isLoadingAccountFiat) + } else { + Text(store.accountTotalFiat) + .font(.title.weight(.medium)) } - Button(action: { action(true) }) { - HStack { - Image(braveSystemName: "leo.edit.pencil") - .font(.body) - Text(Strings.Wallet.renameButtonTitle) - .font(.footnote.weight(.bold)) - } + Text(store.account.accountSupportDisplayString) + .font(.caption) + } + + Spacer().frame(height: 24) + + HStack(spacing: 24) { + PortfolioHeaderButton(style: .buy) { + buySendSwapDestination = .init(kind: .buy) } + PortfolioHeaderButton(style: .send) { + buySendSwapDestination = .init(kind: .send) + } + PortfolioHeaderButton(style: .swap) { + buySendSwapDestination = .init(kind: .swap) + } + } + } + .padding(.vertical, 24) + .frame(maxWidth: .infinity) + .background( + Color(braveSystemName: .containerBackground) + ) + } + + private var rowsSection: some View { + VStack(spacing: 0) { + NavigationLink(destination: { + AssetsListDetailView( + store: store, + cryptoStore: cryptoStore, + keyringStore: keyringStore + ) + }, label: { + let assetsCount = store.userAssets.count + RowView( + iconBraveSystemName: "leo.crypto.wallets", + title: Strings.Wallet.assetsTitle, + description: String.localizedStringWithFormat( + assetsCount == 1 ? + Strings.Wallet.assetsSingularDescription : Strings.Wallet.assetsDescription, + assetsCount + ) + ) + }) + Divider() + NavigationLink(destination: { + NFTGridDetailView( + store: store, + cryptoStore: cryptoStore, + keyringStore: keyringStore + ) + }, label: { + let nftCount = store.userNFTs.count + RowView( + iconBraveSystemName: "leo.grid04", + title: Strings.Wallet.nftsTitle, + description: String.localizedStringWithFormat( + nftCount == 1 ? + Strings.Wallet.nftsSingularDescription : Strings.Wallet.nftsDescription, + nftCount + ) + ) + }) + Divider() + NavigationLink(destination: { + AccountTransactionListView( + activityStore: store, + networkStore: cryptoStore.networkStore + ) + }, label: { + let transactionCount = store.transactionSections.flatMap(\.transactions).count + RowView( + iconBraveSystemName: "leo.history", + title: Strings.Wallet.transactionsTitle, + description: String.localizedStringWithFormat( + transactionCount == 1 ? + Strings.Wallet.transactionsSingularDescription : Strings.Wallet.transactionsDescription, + transactionCount + ) + ) + }) + if WalletConstants.supportedCoinTypes(.dapps).contains(store.account.coin) { + Divider() + NavigationLink(destination: { + DappsSettings( + coin: store.account.coin, + siteConnectionStore: cryptoStore.settingsStore.manageSiteConnectionsStore(keyringStore: keyringStore) + ) + }, label: { + RowView( + iconBraveSystemName: "leo.lock.dots", + title: Strings.Wallet.securityTitle, + description: Strings.Wallet.accountSecurityDescription + ) + }) } - .buttonStyle(BraveOutlineButtonStyle(size: .normal)) } + .background( + Color(braveSystemName: .containerBackground) + .clipShape(RoundedRectangle(cornerRadius: 12)) + ) .padding() + .background( + Color(braveSystemName: .pageBackground) + ) + } + + private struct RowView: View { + let iconBraveSystemName: String + let title: String + let description: String + + var body: some View { + HStack(spacing: 16) { + Circle() + .fill(Color(braveSystemName: .pageBackground)) + .frame(width: 40, height: 40) + .overlay { + Image(braveSystemName: iconBraveSystemName) + .foregroundColor(Color(braveSystemName: .iconDefault)) + } + VStack(alignment: .leading) { + Text(title) + .font(.callout.weight(.semibold)) + .foregroundColor(Color(braveSystemName: .textPrimary)) + Text(description) + .font(.footnote) + .foregroundColor(Color(braveSystemName: .textSecondary)) + } + Spacer() + Image(systemName: "chevron.right") + .font(.body.weight(.semibold)) + .foregroundColor(Color(.separator)) + } + .padding(.vertical, 10) + .padding(.horizontal, 16) + } } } #if DEBUG struct AccountActivityView_Previews: PreviewProvider { static var previews: some View { - AccountActivityView( - keyringStore: .previewStore, - activityStore: .previewStore, - networkStore: .previewStore - ) + NavigationView { + AccountActivityView( + store: .previewStore, + cryptoStore: .previewStore, + keyringStore: .previewStore, + buySendSwapDestination: .constant(.none) + ) + } .previewColorSchemes() } } #endif + +private struct AssetsListDetailView: View { + + @ObservedObject var store: AccountActivityStore + var cryptoStore: CryptoStore + var keyringStore: KeyringStore + @State private var assetForDetails: BraveWallet.BlockchainToken? + + var body: some View { + AssetsListView( + assets: store.userAssets, + currencyFormatter: store.currencyFormatter, + selectedAsset: { asset in + assetForDetails = asset + } + ) + .navigationTitle(Strings.Wallet.assetsTitle) + .background( + NavigationLink( + isActive: Binding( + get: { assetForDetails != nil }, + set: { if !$0 { assetForDetails = nil } } + ), + destination: { + if let token = assetForDetails { + AssetDetailView( + assetDetailStore: cryptoStore.assetDetailStore(for: .blockchainToken(token)), + keyringStore: keyringStore, + networkStore: cryptoStore.networkStore + ) + .onDisappear { + cryptoStore.closeAssetDetailStore(for: .blockchainToken(token)) + } + } + }, + label: { + EmptyView() + }) + ) + } +} + +private struct NFTGridDetailView: View { + + @ObservedObject var store: AccountActivityStore + var cryptoStore: CryptoStore + var keyringStore: KeyringStore + + @State private var nftForDetails: BraveWallet.BlockchainToken? + @Environment(\.buySendSwapDestination) + private var buySendSwapDestination: Binding + + var body: some View { + NFTsGridView( + assets: store.userNFTs, + selectedAsset: { nft in + nftForDetails = nft + } + ) + .navigationTitle(Strings.Wallet.nftsTitle) + .background( + NavigationLink( + isActive: Binding( + get: { nftForDetails != nil }, + set: { if !$0 { nftForDetails = nil } } + ), + destination: { + if let token = nftForDetails { + NFTDetailView( + keyringStore: keyringStore, + nftDetailStore: cryptoStore.nftDetailStore(for: token, nftMetadata: nil, owner: nil), + buySendSwapDestination: buySendSwapDestination + ) { metadata in + + } + .onDisappear { + cryptoStore.closeNFTDetailStore(for: token) + } + } + }, + label: { + EmptyView() + }) + ) + } +} + diff --git a/Sources/BraveWallet/Crypto/AssetsListView.swift b/Sources/BraveWallet/Crypto/AssetsListView.swift new file mode 100644 index 000000000000..0729877e7f3d --- /dev/null +++ b/Sources/BraveWallet/Crypto/AssetsListView.swift @@ -0,0 +1,58 @@ +/* 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 AssetsListView: View { + + let assets: [AssetViewModel] + let currencyFormatter: NumberFormatter + let selectedAsset: (BraveWallet.BlockchainToken) -> Void + + var body: some View { + ScrollView { + LazyVStack { + if assets.isEmpty { + emptyAssetsState + } else { + ForEach(assets) { asset in + Button(action: { + selectedAsset(asset.token) + }) { + PortfolioAssetView( + image: AssetIconView( + token: asset.token, + network: asset.network, + shouldShowNetworkIcon: true + ), + title: asset.token.name, + symbol: asset.token.symbol, + networkName: asset.network.chainName, + amount: asset.fiatAmount(currencyFormatter: currencyFormatter), + quantity: asset.quantity, + shouldHideBalance: true + ) + } + } + } + } + .padding() + } + .background(Color(braveSystemName: .containerBackground)) + } + + private var emptyAssetsState: some View { + VStack(spacing: 10) { + Image("portfolio-empty", bundle: .module) + .aspectRatio(contentMode: .fit) + Text(Strings.Wallet.portfolioEmptyStateTitle) + .font(.headline) + .foregroundColor(Color(WalletV2Design.textPrimary)) + } + .multilineTextAlignment(.center) + .padding(.vertical) + } +} diff --git a/Sources/BraveWallet/Crypto/CryptoView.swift b/Sources/BraveWallet/Crypto/CryptoView.swift index d62dcbb8eb28..fed1f33a5a3e 100644 --- a/Sources/BraveWallet/Crypto/CryptoView.swift +++ b/Sources/BraveWallet/Crypto/CryptoView.swift @@ -129,7 +129,6 @@ public struct CryptoView: View { case .transactionHistory: NavigationView { AccountTransactionListView( - keyringStore: walletStore.keyringStore, activityStore: store.accountActivityStore( for: walletStore.keyringStore.selectedAccount, observeAccountUpdates: true diff --git a/Sources/BraveWallet/Crypto/NFTsGridView.swift b/Sources/BraveWallet/Crypto/NFTsGridView.swift new file mode 100644 index 000000000000..7061ff52385f --- /dev/null +++ b/Sources/BraveWallet/Crypto/NFTsGridView.swift @@ -0,0 +1,117 @@ +/* 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 +import Preferences + +struct NFTsGridView: View { + + let assets: [NFTAssetViewModel] + let selectedAsset: (BraveWallet.BlockchainToken) -> Void + + private let nftGrids = [GridItem(.adaptive(minimum: 120), spacing: 16, alignment: .top)] + + var body: some View { + ScrollView { + if assets.isEmpty { + emptyView + } else { + nftGrid + } + } + .background(Color(braveSystemName: .containerBackground)) + } + + private var emptyView: some View { + VStack(alignment: .center, spacing: 10) { + Text(Strings.Wallet.nftPageEmptyTitle) + .font(.headline.weight(.semibold)) + .foregroundColor(Color(.braveLabel)) + } + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + .padding(.horizontal, 32) + } + + private var nftGrid: some View { + LazyVGrid(columns: nftGrids) { + ForEach(assets) { nft in + Button(action: { + selectedAsset(nft.token) + }) { + VStack(alignment: .leading, spacing: 4) { + nftImage(nft) + .overlay(alignment: .bottomTrailing) { + nftLogo(nft) + .offset(y: 12) + } + .padding(.bottom, 8) + Text(nft.token.nftTokenTitle) + .font(.callout.weight(.medium)) + .foregroundColor(Color(.braveLabel)) + .multilineTextAlignment(.leading) + if !nft.token.symbol.isEmpty { + Text(nft.token.symbol) + .font(.caption) + .foregroundColor(Color(.secondaryBraveLabel)) + .multilineTextAlignment(.leading) + } + } + .overlay(alignment: .topLeading) { + if nft.token.isSpam { + HStack(spacing: 4) { + Text(Strings.Wallet.nftSpam) + .padding(.vertical, 4) + .padding(.leading, 6) + .foregroundColor(Color(.braveErrorLabel)) + Image(braveSystemName: "leo.warning.triangle-outline") + .padding(.vertical, 4) + .padding(.trailing, 6) + .foregroundColor(Color(.braveErrorBorder)) + } + .font(.system(size: 13).weight(.semibold)) + .background( + Color(uiColor: WalletV2Design.spamNFTLabelBackground) + .cornerRadius(4) + ) + .padding(12) + } + } + } + } + } + .padding() + } + + @ViewBuilder private func nftImage(_ nftViewModel: NFTAssetViewModel) -> some View { + Group { + if let urlString = nftViewModel.nftMetadata?.imageURLString { + NFTImageView(urlString: urlString) { + LoadingNFTView(shimmer: false) + } + } else { + LoadingNFTView(shimmer: false) + } + } + .cornerRadius(4) + } + + @ViewBuilder private func nftLogo(_ nftViewModel: NFTAssetViewModel) -> some View { + if let image = nftViewModel.network.nativeTokenLogoImage, + Preferences.Wallet.isShowingNFTNetworkLogoFilter.value { + Image(uiImage: image) + .resizable() + .overlay { + Circle() + .stroke(lineWidth: 2) + .foregroundColor(Color(braveSystemName: .containerBackground)) + } + .frame(width: 24, height: 24) + } + } +} + diff --git a/Sources/BraveWallet/Crypto/Stores/AccountActivityStore.swift b/Sources/BraveWallet/Crypto/Stores/AccountActivityStore.swift index 1c79511223ae..1d7b0a1202a9 100644 --- a/Sources/BraveWallet/Crypto/Stores/AccountActivityStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/AccountActivityStore.swift @@ -11,10 +11,18 @@ class AccountActivityStore: ObservableObject, WalletObserverStore { /// In some cases, we do not want to update the account displayed when the /// selected account changes (ex. when removing an account). let observeAccountUpdates: Bool - private(set) var account: BraveWallet.AccountInfo + @Published private(set) var account: BraveWallet.AccountInfo { + didSet { + guard oldValue != account else { return } + tokenBalanceCache.removeAll() + } + } + @Published private(set) var isLoadingAccountFiat: Bool = false + @Published private(set) var accountTotalFiat: String = "$0.00" @Published private(set) var userAssets: [AssetViewModel] = [] @Published private(set) var userNFTs: [NFTAssetViewModel] = [] - @Published var transactionSummaries: [TransactionSummary] = [] + /// Sections of transactions for display. Each section represents one date. + @Published var transactionSections: [TransactionSection] = [] @Published private(set) var currencyCode: String = CurrencyCode.usd.code { didSet { currencyFormatter.currencyCode = currencyCode @@ -37,6 +45,10 @@ class AccountActivityStore: ObservableObject, WalletObserverStore { /// Cache for storing `BlockchainToken`s that are not in user assets or our token registry. /// This could occur with a dapp creating a transaction. private var tokenInfoCache: [BraveWallet.BlockchainToken] = [] + private var tokenBalanceCache: [String: Double] = [:] + private var tokenPricesCache: [String: String] = [:] + private var nftMetadataCache: [String: NFTMetadata] = [:] + private var solEstimatedTxFeesCache: [String: UInt64] = [:] private var keyringServiceObserver: KeyringServiceObserver? private var rpcServiceObserver: JsonRpcServiceObserver? @@ -44,7 +56,8 @@ class AccountActivityStore: ObservableObject, WalletObserverStore { private var walletServiceObserver: WalletServiceObserver? var isObserving: Bool { - keyringServiceObserver != nil && rpcServiceObserver != nil && txServiceObserver != nil && walletServiceObserver != nil + keyringServiceObserver != nil && rpcServiceObserver != nil + && txServiceObserver != nil && walletServiceObserver != nil } init( @@ -91,6 +104,15 @@ class AccountActivityStore: ObservableObject, WalletObserverStore { guard !isObserving else { return } self.keyringServiceObserver = KeyringServiceObserver( keyringService: keyringService, + _accountsChanged: { + Task { @MainActor in + let allAccounts = await self.keyringService.allAccounts() + if let account = allAccounts.accounts.first(where: { $0.accountId == self.account.accountId }) { + // user may have updated the account name + self.account = account + } + } + }, _selectedWalletAccountChanged: { [weak self] account in guard let self, self.observeAccountUpdates else { return } self.account = account @@ -130,189 +152,242 @@ class AccountActivityStore: ObservableObject, WalletObserverStore { func update() { Task { @MainActor in - let coin = account.coin - let networksForAccountCoin = await rpcService.allNetworks(coin) - .filter { $0.chainId != BraveWallet.LocalhostChainId } // localhost not supported + let networksForAccountCoin = await rpcService.allNetworks(for: [account.coin]) let networksForAccount = networksForAccountCoin.filter { // .fil coin type has two different keyring ids $0.supportedKeyrings.contains(account.keyringId.rawValue as NSNumber) } - - struct NetworkAssets: Equatable { - let network: BraveWallet.NetworkInfo - let tokens: [BraveWallet.BlockchainToken] - let sortOrder: Int - } - let allUserAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: networksForAccount, includingUserDeleted: true) + // Include user deleted for case user sent an NFT + // then deleted it, we need it for display in transaction list + let allUserNetworkAssets = assetManager.getAllUserAssetsInNetworkAssets( + networks: networksForAccount, + includingUserDeleted: true + ) + let allUserAssets = allUserNetworkAssets.flatMap(\.tokens) let allTokens = await blockchainRegistry.allTokens(in: networksForAccountCoin).flatMap(\.tokens) - var updatedUserAssets: [AssetViewModel] = [] - var updatedUserNFTs: [NFTAssetViewModel] = [] - for networkAssets in allUserAssets { - for token in networkAssets.tokens { - if token.isErc721 || token.isNft { - updatedUserNFTs.append( - NFTAssetViewModel( - groupType: .none, - token: token, - network: networkAssets.network, - balanceForAccounts: [:] - ) - ) - } else { - updatedUserAssets.append( - AssetViewModel( - groupType: .none, - token: token, - network: networkAssets.network, - price: "", - history: [], - balanceForAccounts: [:] - ) - ) - } - } - } - self.userAssets = updatedUserAssets - self.userNFTs = updatedUserNFTs + (self.userAssets, self.userNFTs) = buildAssetsAndNFTs( + userNetworkAssets: allUserNetworkAssets, + tokenBalances: tokenBalanceCache, + tokenPrices: tokenPricesCache, + nftMetadata: nftMetadataCache + ) + let allAccountsForCoin = await keyringService.allAccounts().accounts.filter { $0.coin == account.coin } + let transactions = await txService.allTransactions(networks: networksForAccountCoin, for: account) + self.transactionSections = buildTransactionSections( + transactions: transactions, + networksForCoin: [account.coin: networksForAccountCoin], + accountInfos: allAccountsForCoin, + userAssets: allUserAssets, + allTokens: allTokens, + tokenPrices: tokenPricesCache, + nftMetadata: nftMetadataCache, + solEstimatedTxFees: solEstimatedTxFeesCache + ) - typealias TokenNetworkAccounts = (token: BraveWallet.BlockchainToken, network: BraveWallet.NetworkInfo, accounts: [BraveWallet.AccountInfo]) - let allTokenNetworkAccounts = allUserAssets.flatMap { networkAssets in - networkAssets.tokens.map { token in - TokenNetworkAccounts( - token: token, - network: networkAssets.network, - accounts: [account] - ) - } - } - let totalBalances: [String: Double] = await withTaskGroup(of: [String: Double].self, body: { @MainActor group in - for tokenNetworkAccounts in allTokenNetworkAccounts { - group.addTask { @MainActor in - let totalBalance = await self.rpcService.fetchTotalBalance( - token: tokenNetworkAccounts.token, - network: tokenNetworkAccounts.network, - accounts: tokenNetworkAccounts.accounts - ) - return [tokenNetworkAccounts.token.assetBalanceId: totalBalance] - } - } - return await group.reduce(into: [String: Double](), { partialResult, new in - for key in new.keys { - partialResult[key] = new[key] - } - }) - }) + self.isLoadingAccountFiat = true + let tokenBalances = await self.rpcService.fetchBalancesForTokens( + account: account, + networkAssets: allUserNetworkAssets + ) + tokenBalanceCache.merge(with: tokenBalances) // fetch price for every user asset - let allUserAssetsInToken = allUserAssets.flatMap(\.tokens) - let allUserAssetsAssetRatioIds = allUserAssetsInToken.map(\.assetRatioId) let prices: [String: String] = await assetRatioService.fetchPrices( - for: allUserAssetsAssetRatioIds, + for: allUserAssets.map(\.assetRatioId), toAssets: [currencyFormatter.currencyCode], timeframe: .oneDay ) + tokenPricesCache.merge(with: prices) + + var totalFiat: Double = 0 + for (key, balance) in tokenBalances where balance > 0 { + if let token = allUserAssets.first(where: { $0.id == key }), + let priceString = prices[token.assetRatioId.lowercased()], + let price = Double(priceString) { + let tokenFiat = balance * price + totalFiat += tokenFiat + } + } + self.accountTotalFiat = currencyFormatter.string(from: .init(value: totalFiat)) ?? "$0.00" + self.isLoadingAccountFiat = false + + guard !Task.isCancelled else { return } + // update assets, NFTs, transactions after balance & price fetch + (self.userAssets, self.userNFTs) = buildAssetsAndNFTs( + userNetworkAssets: allUserNetworkAssets, + tokenBalances: tokenBalanceCache, + tokenPrices: tokenPricesCache, + nftMetadata: nftMetadataCache + ) + self.transactionSections = buildTransactionSections( + transactions: transactions, + networksForCoin: [account.coin: networksForAccountCoin], + accountInfos: allAccountsForCoin, + userAssets: allUserAssets, + allTokens: allTokens, + tokenPrices: tokenPricesCache, + nftMetadata: nftMetadataCache, + solEstimatedTxFees: solEstimatedTxFeesCache + ) // fetch NFTs metadata let allNFTMetadata = await rpcService.fetchNFTMetadata( - tokens: userNFTs - .map(\.token) - .filter({ $0.isErc721 || $0.isNft }), + tokens: userNFTs.map(\.token), ipfsApi: ipfsApi ) + nftMetadataCache.merge(with: allNFTMetadata) guard !Task.isCancelled else { return } - updatedUserAssets.removeAll() - updatedUserNFTs.removeAll() - for networkAssets in allUserAssets { - for token in networkAssets.tokens { - if token.isErc721 || token.isNft { - updatedUserNFTs.append( - NFTAssetViewModel( - groupType: .none, - token: token, - network: networkAssets.network, - balanceForAccounts: [account.address: Int(totalBalances[token.assetBalanceId] ?? 0)], - nftMetadata: allNFTMetadata[token.id] - ) + (self.userAssets, self.userNFTs) = buildAssetsAndNFTs( + userNetworkAssets: allUserNetworkAssets, + tokenBalances: tokenBalanceCache, + tokenPrices: tokenPricesCache, + nftMetadata: nftMetadataCache + ) + self.transactionSections = buildTransactionSections( + transactions: transactions, + networksForCoin: [account.coin: networksForAccountCoin], + accountInfos: allAccountsForCoin, + userAssets: allUserAssets, + allTokens: allTokens, + tokenPrices: tokenPricesCache, + nftMetadata: nftMetadataCache, + solEstimatedTxFees: solEstimatedTxFeesCache + ) + + if !transactions.isEmpty { + var solEstimatedTxFees: [String: UInt64] = [:] + switch account.coin { + case .eth: + // Gather known information about the transaction(s) tokens + let unknownTokenInfo = transactions.unknownTokenContractAddressChainIdPairs( + knownTokens: allUserAssets + allTokens + tokenInfoCache + ) + if !unknownTokenInfo.isEmpty { + let unknownTokens: [BraveWallet.BlockchainToken] = await rpcService.fetchEthTokens(for: unknownTokenInfo) + tokenInfoCache.append(contentsOf: unknownTokens) + } + case .sol: + solEstimatedTxFees = await solTxManagerProxy.estimatedTxFees(for: transactions) + self.solEstimatedTxFeesCache.merge(with: solEstimatedTxFees) + default: + break + } + self.transactionSections = buildTransactionSections( + transactions: transactions, + networksForCoin: [account.coin: networksForAccountCoin], + accountInfos: allAccountsForCoin, + userAssets: allUserAssets, + allTokens: allTokens, + tokenPrices: tokenPricesCache, + nftMetadata: allNFTMetadata, + solEstimatedTxFees: solEstimatedTxFeesCache + ) + } + } + } + + private func buildAssetsAndNFTs( + userNetworkAssets: [NetworkAssets], + tokenBalances: [String: Double], + tokenPrices: [String: String], + nftMetadata: [String: NFTMetadata] + ) -> ([AssetViewModel], [NFTAssetViewModel]) { + var updatedUserAssets: [AssetViewModel] = [] + var updatedUserNFTs: [NFTAssetViewModel] = [] + for networkAssets in userNetworkAssets { + for token in networkAssets.tokens { + if token.isErc721 || token.isNft { + guard Int(tokenBalances[token.id] ?? 0) > 0 else { + // only show NFTs belonging to this account + continue + } + updatedUserNFTs.append( + NFTAssetViewModel( + groupType: .none, + token: token, + network: networkAssets.network, + balanceForAccounts: [account.address: Int(tokenBalances[token.id] ?? 0)], + nftMetadata: nftMetadata[token.id] ) - } else { - updatedUserAssets.append( - AssetViewModel( - groupType: .none, - token: token, - network: networkAssets.network, - price: prices[token.assetRatioId.lowercased()] ?? "", - history: [], - balanceForAccounts: [account.address: totalBalances[token.assetBalanceId] ?? 0] - ) + ) + } else { + updatedUserAssets.append( + AssetViewModel( + groupType: .none, + token: token, + network: networkAssets.network, + price: tokenPrices[token.assetRatioId.lowercased()] ?? "", + history: [], + balanceForAccounts: [account.address: tokenBalances[token.id] ?? 0] ) - } + ) } } - self.userAssets = updatedUserAssets - self.userNFTs = updatedUserNFTs - - let assetRatios = self.userAssets.reduce(into: [String: Double](), { - $0[$1.token.assetRatioId.lowercased()] = Double($1.price) - }) - - let allAccountsForCoin = await keyringService.allAccounts().accounts.filter { $0.coin == account.coin } - self.transactionSummaries = await fetchTransactionSummarys( - networksForAccountCoin: networksForAccountCoin, - accountInfos: allAccountsForCoin, - userAssets: userAssets.map(\.token), - allTokens: allTokens, - assetRatios: assetRatios - ) } + updatedUserAssets = updatedUserAssets.sorted(by: { lhs, rhs in + AssetViewModel.sorted(by: .valueDesc, lhs: lhs, rhs: rhs) + }) + + return (updatedUserAssets, updatedUserNFTs) } - @MainActor private func fetchTransactionSummarys( - networksForAccountCoin: [BraveWallet.NetworkInfo], + private func buildTransactionSections( + transactions: [BraveWallet.TransactionInfo], + networksForCoin: [BraveWallet.CoinType: [BraveWallet.NetworkInfo]], accountInfos: [BraveWallet.AccountInfo], userAssets: [BraveWallet.BlockchainToken], allTokens: [BraveWallet.BlockchainToken], - assetRatios: [String: Double] - ) async -> [TransactionSummary] { - let transactions = await txService.allTransactions(networks: networksForAccountCoin, for: account) - var solEstimatedTxFees: [String: UInt64] = [:] - if account.coin == .sol { - solEstimatedTxFees = await solTxManagerProxy.estimatedTxFees(for: transactions) + tokenPrices: [String: String], + nftMetadata: [String: NFTMetadata], + solEstimatedTxFees: [String: UInt64] + ) -> [TransactionSection] { + // Group transactions by day (only compare day/month/year) + let transactionsGroupedByDate = Dictionary(grouping: transactions) { transaction in + let dateComponents = Calendar.current.dateComponents([.year, .month, .day], from: transaction.createdTime) + return Calendar.current.date(from: dateComponents) ?? transaction.createdTime } - let ethTransactions = transactions.filter { $0.coin == .eth } - if !ethTransactions.isEmpty { - // Gather known information about the transaction(s) tokens - let unknownTokenInfo = ethTransactions.unknownTokenContractAddressChainIdPairs( - knownTokens: userAssets + allTokens + tokenInfoCache + let tokenPrices = self.userAssets.reduce(into: [String: Double](), { + $0[$1.token.assetRatioId.lowercased()] = Double($1.price) + }) + // Map to 1 `TransactionSection` per date + return transactionsGroupedByDate.keys.sorted(by: { $0 > $1 }).compactMap { date in + let transactions = transactionsGroupedByDate[date] ?? [] + guard !transactions.isEmpty else { return nil } + let parsedTransactions: [ParsedTransaction] = transactions + .sorted(by: { $0.createdTime > $1.createdTime }) + .compactMap { transaction in + guard let networks = networksForCoin[transaction.coin], + let network = networks.first(where: { $0.chainId == transaction.chainId }) else { + return nil + } + return TransactionParser.parseTransaction( + transaction: transaction, + network: network, + accountInfos: accountInfos, + userAssets: userAssets, + allTokens: allTokens + tokenInfoCache, + assetRatios: tokenPrices, + nftMetadata: nftMetadata, + solEstimatedTxFee: solEstimatedTxFees[transaction.id], + currencyFormatter: currencyFormatter, + decimalFormatStyle: .decimals(precision: 4) + ) + } + return TransactionSection( + date: date, + transactions: parsedTransactions ) - if !unknownTokenInfo.isEmpty { - let unknownTokens: [BraveWallet.BlockchainToken] = await rpcService.fetchEthTokens(for: unknownTokenInfo) - tokenInfoCache.append(contentsOf: unknownTokens) - } } - return transactions - .compactMap { transaction in - guard let network = networksForAccountCoin.first(where: { $0.chainId == transaction.chainId }) else { - return nil - } - return TransactionParser.transactionSummary( - from: transaction, - network: network, - accountInfos: accountInfos, - userAssets: userAssets, - allTokens: allTokens + tokenInfoCache, - assetRatios: assetRatios, - nftMetadata: [:], - solEstimatedTxFee: solEstimatedTxFees[transaction.id], - currencyFormatter: currencyFormatter - ) - }.sorted(by: { $0.createdTime > $1.createdTime }) } private var transactionDetailsStore: TransactionDetailsStore? func transactionDetailsStore(for transaction: BraveWallet.TransactionInfo) -> TransactionDetailsStore { + let parsedTransaction = transactionSections + .flatMap(\.transactions) + .first(where: { $0.transaction.id == transaction.id }) let transactionDetailsStore = TransactionDetailsStore( transaction: transaction, - parsedTransaction: nil, + parsedTransaction: parsedTransaction, keyringService: keyringService, walletService: walletService, rpcService: rpcService, @@ -331,110 +406,4 @@ class AccountActivityStore: ObservableObject, WalletObserverStore { self.transactionDetailsStore?.tearDown() self.transactionDetailsStore = nil } - - #if DEBUG - func previewTransactions() { - transactionSummaries = [.previewConfirmedSwap, .previewConfirmedSend, .previewConfirmedERC20Approve] - } - #endif -} - -extension AccountActivityStore: BraveWalletKeyringServiceObserver { - func walletCreated() { - } - - func walletRestored() { - } - - func walletReset() { - } - - func locked() { - } - - func unlocked() { - } - - func backedUp() { - } - - func accountsChanged() { - } - - func autoLockMinutesChanged() { - } - - func selectedWalletAccountChanged(_ account: BraveWallet.AccountInfo) { - guard observeAccountUpdates else { return } - self.account = account - update() - } - - func selectedDappAccountChanged(_ coin: BraveWallet.CoinType, account: BraveWallet.AccountInfo?) { - guard observeAccountUpdates, let account else { return } - self.account = account - update() - } - - func accountsAdded(_ addedAccounts: [BraveWallet.AccountInfo]) { - } -} - -extension AccountActivityStore: BraveWalletJsonRpcServiceObserver { - func chainChangedEvent(_ chainId: String, coin: BraveWallet.CoinType, origin: URLOrigin?) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - // Handle small gap between chain changing and txController having the correct chain Id - self.update() - } - } - func onAddEthereumChainRequestCompleted(_ chainId: String, error: String) { - } - func onIsEip1559Changed(_ chainId: String, isEip1559: Bool) { - } -} - -extension AccountActivityStore: BraveWalletTxServiceObserver { - func onNewUnapprovedTx(_ txInfo: BraveWallet.TransactionInfo) { - update() - } - func onTransactionStatusChanged(_ txInfo: BraveWallet.TransactionInfo) { - update() - } - func onUnapprovedTxUpdated(_ txInfo: BraveWallet.TransactionInfo) { - } - func onTxServiceReset() { - } -} - -extension AccountActivityStore: BraveWalletBraveWalletServiceObserver { - public func onActiveOriginChanged(_ originInfo: BraveWallet.OriginInfo) { - } - - public func onDefaultWalletChanged(_ wallet: BraveWallet.DefaultWallet) { - } - - public func onDefaultBaseCurrencyChanged(_ currency: String) { - currencyCode = currency - } - - public func onDefaultBaseCryptocurrencyChanged(_ cryptocurrency: String) { - } - - public func onNetworkListChanged() { - } - - func onDefaultEthereumWalletChanged(_ wallet: BraveWallet.DefaultWallet) { - } - - func onDefaultSolanaWalletChanged(_ wallet: BraveWallet.DefaultWallet) { - } - - public func onDiscoverAssetsStarted() { - } - - func onDiscoverAssetsCompleted(_ discoveredAssets: [BraveWallet.BlockchainToken]) { - } - - func onResetWallet() { - } } diff --git a/Sources/BraveWallet/Crypto/Transactions/TransactionParser.swift b/Sources/BraveWallet/Crypto/Transactions/TransactionParser.swift index 953a1b311154..b0f3f51ae139 100644 --- a/Sources/BraveWallet/Crypto/Transactions/TransactionParser.swift +++ b/Sources/BraveWallet/Crypto/Transactions/TransactionParser.swift @@ -22,6 +22,11 @@ enum TransactionParser { // Show additional decimal places for gas fee calculations (Solana has low tx fees). currencyFormatter.minimumFractionDigits = 2 currencyFormatter.maximumFractionDigits = 10 + defer { + // Restore previous fraction digits + currencyFormatter.minimumFractionDigits = existingMinimumFractionDigits + currencyFormatter.maximumFractionDigits = existingMaximumFractionDigits + } switch network.coin { case .eth: let isEIP1559Transaction = transaction.isEIP1559Transaction @@ -78,9 +83,6 @@ enum TransactionParser { @unknown default: break } - // Restore previous fraction digits - currencyFormatter.minimumFractionDigits = existingMinimumFractionDigits - currencyFormatter.maximumFractionDigits = existingMaximumFractionDigits return gasFee } diff --git a/Sources/BraveWallet/Crypto/Transactions/TransactionsListView.swift b/Sources/BraveWallet/Crypto/Transactions/TransactionsListView.swift index daec4ed00ccd..f8d06cbd32ed 100644 --- a/Sources/BraveWallet/Crypto/Transactions/TransactionsListView.swift +++ b/Sources/BraveWallet/Crypto/Transactions/TransactionsListView.swift @@ -119,50 +119,6 @@ struct TransactionsListView: View { } } -private struct SearchBar: UIViewRepresentable { - - @Binding var text: String - var placeholder = "" - - func makeUIView(context: Context) -> UISearchBar { - let searchBar = UISearchBar(frame: .zero) - searchBar.text = text - searchBar.placeholder = placeholder - // remove black divider lines above/below field - searchBar.searchBarStyle = .minimal - // don't disable 'Search' when field empty - searchBar.enablesReturnKeyAutomatically = false - return searchBar - } - - func updateUIView(_ uiView: UISearchBar, context: Context) { - uiView.text = text - uiView.placeholder = placeholder - uiView.delegate = context.coordinator - } - - func makeCoordinator() -> Coordinator { - Coordinator(text: $text) - } - - class Coordinator: NSObject, UISearchBarDelegate { - @Binding var text: String - - init(text: Binding) { - _text = text - } - - func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - text = searchText - } - - func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { - // dismiss keyboard when 'Search' / return key tapped - searchBar.resignFirstResponder() - } - } -} - #if DEBUG struct TransactionsListView_Previews: PreviewProvider { @State private static var query: String = "" diff --git a/Sources/BraveWallet/SearchBar.swift b/Sources/BraveWallet/SearchBar.swift new file mode 100644 index 000000000000..75696bf30de4 --- /dev/null +++ b/Sources/BraveWallet/SearchBar.swift @@ -0,0 +1,50 @@ +/* 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 + +struct SearchBar: UIViewRepresentable { + + @Binding var text: String + var placeholder = "" + + func makeUIView(context: Context) -> UISearchBar { + let searchBar = UISearchBar(frame: .zero) + searchBar.text = text + searchBar.placeholder = placeholder + // remove black divider lines above/below field + searchBar.searchBarStyle = .minimal + // don't disable 'Search' when field empty + searchBar.enablesReturnKeyAutomatically = false + return searchBar + } + + func updateUIView(_ uiView: UISearchBar, context: Context) { + uiView.text = text + uiView.placeholder = placeholder + uiView.delegate = context.coordinator + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text) + } + + class Coordinator: NSObject, UISearchBarDelegate { + @Binding var text: String + + init(text: Binding) { + _text = text + } + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + text = searchText + } + + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + // dismiss keyboard when 'Search' / return key tapped + searchBar.resignFirstResponder() + } + } +} diff --git a/Sources/BraveWallet/WalletStrings.swift b/Sources/BraveWallet/WalletStrings.swift index 144e05605430..66f7a8ffdcdc 100644 --- a/Sources/BraveWallet/WalletStrings.swift +++ b/Sources/BraveWallet/WalletStrings.swift @@ -4850,5 +4850,61 @@ extension Strings { value: "Search", comment: "The label as a placeholder in search fields." ) + public static let securityTitle = NSLocalizedString( + "wallet.securityTitle", + tableName: "BraveWallet", + bundle: .module, + value: "Security", + comment: "The title used in the row opening DApp settings in Account Details." + ) + public static let accountSecurityDescription = NSLocalizedString( + "wallet.accountSecurityDescription", + tableName: "BraveWallet", + bundle: .module, + value: "Connected Sites and Allowances", + comment: "The description used below Security title in the row opening DApp settings in Account Details." + ) + public static let assetsSingularDescription = NSLocalizedString( + "wallet.assetsSingularDescription", + tableName: "BraveWallet", + bundle: .module, + value: "%d asset", + comment: "The description of the assets row in Account Details when user has 1 Asset." + ) + public static let assetsDescription = NSLocalizedString( + "wallet.assetsDescription", + tableName: "BraveWallet", + bundle: .module, + value: "%d assets", + comment: "The description of the assets row in Account Details when user has zero or multiple Assets." + ) + public static let nftsSingularDescription = NSLocalizedString( + "wallet.nftsSingularDescription", + tableName: "BraveWallet", + bundle: .module, + value: "%d NFT", + comment: "The description of the NFTs row in Account Details when user has 1 NFT." + ) + public static let nftsDescription = NSLocalizedString( + "wallet.nftsDescription", + tableName: "BraveWallet", + bundle: .module, + value: "%d NFTs", + comment: "The description of the NFTs row in Account Details when user has zero or multiple NFTs." + ) + public static let transactionsSingularDescription = NSLocalizedString( + "wallet.transactionsSingularDescription", + tableName: "BraveWallet", + bundle: .module, + value: "%d transactions", + comment: "The description of the transactions row in Account Details when user has 1 Transaction." + ) + public static let transactionsDescription = NSLocalizedString( + "wallet.transactionsDescription", + tableName: "BraveWallet", + bundle: .module, + value: "%d transactions", + comment: "The description of the transactions row in Account Details when user has zero or multiple Transactions." + ) } } diff --git a/Sources/DesignSystem/Icons/Symbols.xcassets/leo.grid04.symbolset/Contents.json b/Sources/DesignSystem/Icons/Symbols.xcassets/leo.grid04.symbolset/Contents.json new file mode 100644 index 000000000000..2f415ce6e4c0 --- /dev/null +++ b/Sources/DesignSystem/Icons/Symbols.xcassets/leo.grid04.symbolset/Contents.json @@ -0,0 +1,11 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "idiom" : "universal" + } + ] +} diff --git a/Sources/DesignSystem/Icons/Symbols.xcassets/leo.lock.dots.symbolset/Contents.json b/Sources/DesignSystem/Icons/Symbols.xcassets/leo.lock.dots.symbolset/Contents.json new file mode 100644 index 000000000000..2f415ce6e4c0 --- /dev/null +++ b/Sources/DesignSystem/Icons/Symbols.xcassets/leo.lock.dots.symbolset/Contents.json @@ -0,0 +1,11 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "idiom" : "universal" + } + ] +} diff --git a/Tests/BraveWalletTests/AccountActivityStoreTests.swift b/Tests/BraveWalletTests/AccountActivityStoreTests.swift index 70bec659049e..0943d5ba33ff 100644 --- a/Tests/BraveWalletTests/AccountActivityStoreTests.swift +++ b/Tests/BraveWalletTests/AccountActivityStoreTests.swift @@ -7,6 +7,7 @@ import Combine import XCTest import BraveCore @testable import BraveWallet +import Preferences class AccountActivityStoreTests: XCTestCase { @@ -51,10 +52,15 @@ class AccountActivityStoreTests: XCTestCase { completion(self.networks[coin] ?? []) } rpcService._balance = { _, coin, chainId, completion in - if coin == .eth { - completion(mockEthBalanceWei, .success, "") // eth balance - } else { // .fil - completion(chainId == BraveWallet.FilecoinMainnet ? mockFilBalance : mockFilTestnetBalance, .success, "") + switch chainId { + case BraveWallet.MainnetChainId: + completion(mockEthBalanceWei, .success, "") + case BraveWallet.FilecoinMainnet: + completion(mockFilBalance, .success, "") + case BraveWallet.FilecoinTestnet: + completion(mockFilTestnetBalance, .success, "") + default: + completion("", .internalError, "") } } rpcService._erc20TokenBalance = { _, _, _, completion in @@ -64,7 +70,11 @@ class AccountActivityStoreTests: XCTestCase { completion(mockERC721BalanceWei, .success, "") // eth nft balance } rpcService._solanaBalance = { accountAddress, chainId, completion in - completion(mockLamportBalance, .success, "") // sol balance + if chainId == BraveWallet.SolanaMainnet { + completion(mockLamportBalance, .success, "") + } else { // testnet balance + completion(0, .success, "") + } } rpcService._splTokenAccountBalance = { _, tokenMintAddress, _, completion in // spd token, sol nft balance @@ -120,7 +130,8 @@ class AccountActivityStoreTests: XCTestCase { } func testUpdateEthereumAccount() { - let firstTransactionDate = Date(timeIntervalSince1970: 1636399671) // Monday, November 8, 2021 7:27:51 PM + Preferences.Wallet.showTestNetworks.value = true + let firstTransactionDate = Date(timeIntervalSince1970: 1636399671) // Monday, November 8, 2021 7:27:51 PM let account: BraveWallet.AccountInfo = .mockEthAccount let formatter = WeiFormatter(decimalFormatStyle: .decimals(precision: 18)) let mockEthDecimalBalance: Double = 0.0896 @@ -143,7 +154,7 @@ class AccountActivityStoreTests: XCTestCase { mockERC721BalanceWei: mockERC721BalanceWei, transactions: [goerliSwapTxCopy, ethSendTxCopy].enumerated().map { (index, tx) in // transactions sorted by created time, make sure they are in-order - tx.createdTime = firstTransactionDate.addingTimeInterval(TimeInterval(index * 10)) + tx.createdTime = firstTransactionDate.addingTimeInterval(TimeInterval(index) * 1.days) return tx } ) @@ -179,13 +190,12 @@ class AccountActivityStoreTests: XCTestCase { userAssetManager: mockAssetManager ) - let userAssetsException = expectation(description: "accountActivityStore-assetStores") + let userAssetsException = expectation(description: "accountActivityStore-userAssets") accountActivityStore.$userAssets .dropFirst() - .collect(2) + .collect(3) .sink { userAssets in defer { userAssetsException.fulfill() } - XCTAssertEqual(userAssets.count, 2) // empty assets, populated assets guard let lastUpdatedAssets = userAssets.last else { XCTFail("Unexpected test result") return @@ -201,17 +211,21 @@ class AccountActivityStoreTests: XCTestCase { XCTAssertEqual(lastUpdatedAssets[1].network, BraveWallet.NetworkInfo.mockMainnet) XCTAssertEqual(lastUpdatedAssets[1].totalBalance, mockERC20DecimalBalance) XCTAssertEqual(lastUpdatedAssets[1].price, self.mockAssetPrices[safe: 1]?.price ?? "") + + XCTAssertEqual(lastUpdatedAssets[2].token.symbol, BraveWallet.NetworkInfo.mockGoerli.nativeToken.symbol) + XCTAssertEqual(lastUpdatedAssets[2].network, BraveWallet.NetworkInfo.mockGoerli) + XCTAssertEqual(lastUpdatedAssets[2].totalBalance, 0) + XCTAssertEqual(lastUpdatedAssets[2].price, self.mockAssetPrices[safe: 0]?.price ?? "") } .store(in: &cancellables) - let userNFTsException = expectation(description: "accountActivityStore-userVisibleNFTs") + let userNFTsException = expectation(description: "accountActivityStore-userNFTs") XCTAssertTrue(accountActivityStore.userNFTs.isEmpty) // Initial state accountActivityStore.$userNFTs .dropFirst() - .collect(2) + .collect(3) .sink { userNFTs in defer { userNFTsException.fulfill() } - XCTAssertEqual(userNFTs.count, 2) // empty nfts, populated nfts guard let lastUpdatedNFTs = userNFTs.last else { XCTFail("Unexpected test result") return @@ -224,18 +238,26 @@ class AccountActivityStoreTests: XCTestCase { XCTAssertEqual(lastUpdatedNFTs[safe: 0]?.nftMetadata?.description, mockERC721Metadata.description) }.store(in: &cancellables) - let transactionSummariesExpectation = expectation(description: "accountActivityStore-transactions") - XCTAssertTrue(accountActivityStore.transactionSummaries.isEmpty) - accountActivityStore.$transactionSummaries + let transactionSectionsExpectation = expectation(description: "accountActivityStore-transactions") + XCTAssertTrue(accountActivityStore.transactionSections.isEmpty) + accountActivityStore.$transactionSections .dropFirst() - .sink { transactionSummaries in - defer { transactionSummariesExpectation.fulfill() } - // summaries are tested in `TransactionParserTests`, just verify they are populated with correct tx - XCTAssertEqual(transactionSummaries.count, 2) - XCTAssertEqual(transactionSummaries[safe: 0]?.txInfo, ethSendTxCopy) - XCTAssertEqual(transactionSummaries[safe: 0]?.txInfo.chainId, ethSendTxCopy.chainId) - XCTAssertEqual(transactionSummaries[safe: 1]?.txInfo, goerliSwapTxCopy) - XCTAssertEqual(transactionSummaries[safe: 1]?.txInfo.chainId, goerliSwapTxCopy.chainId) + .collect(3) + .sink { transactionSectionsCollected in + defer { transactionSectionsExpectation.fulfill() } + guard let transactionSections = transactionSectionsCollected.last else { + XCTFail("Unexpected test result") + return + } + // `ParsedTransaction`s are tested in `TransactionParserTests`, + // just verify they are populated with correct tx + XCTAssertEqual(transactionSections.count, 2) + let firstSectionTxs = transactionSections[safe: 0]?.transactions ?? [] + XCTAssertEqual(firstSectionTxs[safe: 0]?.transaction, ethSendTxCopy) + XCTAssertEqual(firstSectionTxs[safe: 0]?.transaction.chainId, ethSendTxCopy.chainId) + let secondSectionTxs = transactionSections[safe: 1]?.transactions ?? [] + XCTAssertEqual(secondSectionTxs[safe: 0]?.transaction, goerliSwapTxCopy) + XCTAssertEqual(secondSectionTxs[safe: 0]?.transaction.chainId, goerliSwapTxCopy.chainId) }.store(in: &cancellables) accountActivityStore.update() @@ -246,6 +268,7 @@ class AccountActivityStoreTests: XCTestCase { } func testUpdateSolanaAccount() { + Preferences.Wallet.showTestNetworks.value = true let firstTransactionDate = Date(timeIntervalSince1970: 1636399671) // Monday, November 8, 2021 7:27:51 PM let account: BraveWallet.AccountInfo = .mockSolAccount let mockLamportBalance: UInt64 = 3876535000 // ~3.8765 SOL @@ -270,7 +293,7 @@ class AccountActivityStoreTests: XCTestCase { mockSplTokenBalances: mockSplTokenBalances, transactions: [solTestnetSendTxCopy, solSendTxCopy].enumerated().map { (index, tx) in // transactions sorted by created time, make sure they are in-order - tx.createdTime = firstTransactionDate.addingTimeInterval(TimeInterval(index * 10)) + tx.createdTime = firstTransactionDate.addingTimeInterval(TimeInterval(index) * 1.days) return tx } ) @@ -309,13 +332,12 @@ class AccountActivityStoreTests: XCTestCase { userAssetManager: mockAssetManager ) - let userAssetsExpectation = expectation(description: "accountActivityStore-assetStores") + let userAssetsExpectation = expectation(description: "accountActivityStore-userAssets") accountActivityStore.$userAssets .dropFirst() - .collect(2) + .collect(3) .sink { userAssets in defer { userAssetsExpectation.fulfill() } - XCTAssertEqual(userAssets.count, 2) // empty assets, populated assets guard let lastUpdatedAssets = userAssets.last else { XCTFail("Unexpected test result") return @@ -334,14 +356,13 @@ class AccountActivityStoreTests: XCTestCase { } .store(in: &cancellables) - let userNFTsExpectation = expectation(description: "accountActivityStore-userVisibleNFTs") + let userNFTsExpectation = expectation(description: "accountActivityStore-userNFTs") XCTAssertTrue(accountActivityStore.userNFTs.isEmpty) // Initial state accountActivityStore.$userNFTs .dropFirst() - .collect(2) + .collect(3) .sink { userNFTs in defer { userNFTsExpectation.fulfill() } - XCTAssertEqual(userNFTs.count, 2) // empty nfts, populated nfts guard let lastUpdatedNFTs = userNFTs.last else { XCTFail("Unexpected test result") return @@ -354,18 +375,26 @@ class AccountActivityStoreTests: XCTestCase { XCTAssertEqual(lastUpdatedNFTs[safe: 0]?.nftMetadata?.description, mockSolMetadata.description) }.store(in: &cancellables) - let transactionSummariesExpectation = expectation(description: "accountActivityStore-transactions") - XCTAssertTrue(accountActivityStore.transactionSummaries.isEmpty) - accountActivityStore.$transactionSummaries + let transactionSectionsExpectation = expectation(description: "accountActivityStore-transactions") + XCTAssertTrue(accountActivityStore.transactionSections.isEmpty) + accountActivityStore.$transactionSections .dropFirst() - .sink { transactionSummaries in - defer { transactionSummariesExpectation.fulfill() } - // summaries are tested in `TransactionParserTests`, just verify they are populated with correct tx - XCTAssertEqual(transactionSummaries.count, 2) - XCTAssertEqual(transactionSummaries[safe: 0]?.txInfo, solSendTxCopy) - XCTAssertEqual(transactionSummaries[safe: 0]?.txInfo.chainId, solSendTxCopy.chainId) - XCTAssertEqual(transactionSummaries[safe: 1]?.txInfo, solTestnetSendTxCopy) - XCTAssertEqual(transactionSummaries[safe: 1]?.txInfo.chainId, solTestnetSendTxCopy.chainId) + .collect(3) + .sink { transactionSectionsCollected in + defer { transactionSectionsExpectation.fulfill() } + guard let transactionSections = transactionSectionsCollected.last else { + XCTFail("Unexpected test result") + return + } + // `ParsedTransaction`s are tested in `TransactionParserTests`, + // just verify they are populated with correct tx + XCTAssertEqual(transactionSections.count, 2) + let firstSectionTxs = transactionSections[safe: 0]?.transactions ?? [] + XCTAssertEqual(firstSectionTxs[safe: 0]?.transaction, solSendTxCopy) + XCTAssertEqual(firstSectionTxs[safe: 0]?.transaction.chainId, solSendTxCopy.chainId) + let secondSectionTxs = transactionSections[safe: 1]?.transactions ?? [] + XCTAssertEqual(secondSectionTxs[safe: 0]?.transaction, solTestnetSendTxCopy) + XCTAssertEqual(secondSectionTxs[safe: 0]?.transaction.chainId, solTestnetSendTxCopy.chainId) }.store(in: &cancellables) accountActivityStore.update() @@ -376,6 +405,7 @@ class AccountActivityStoreTests: XCTestCase { } func testUpdateFilecoinAccount() { + Preferences.Wallet.showTestNetworks.value = true let firstTransactionDate = Date(timeIntervalSince1970: 1636399671) // Monday, November 8, 2021 7:27:51 PM let account: BraveWallet.AccountInfo = .mockFilAccount @@ -412,10 +442,10 @@ class AccountActivityStoreTests: XCTestCase { transactionCopy.chainId = BraveWallet.FilecoinTestnet let formatter = WeiFormatter(decimalFormatStyle: .decimals(precision: 18)) - let mockFilDecimalBalance: Double = 1 + let mockFilDecimalBalance: Double = 2 let filecoinMainnetDecimals = Int(BraveWallet.NetworkInfo.mockFilecoinMainnet.decimals) let mockFilDecimalBalanceInWei = formatter.weiString(from: "\(mockFilDecimalBalance)", radix: .decimal, decimals: filecoinMainnetDecimals) ?? "" - let mockFileTestnetDecimalBalance: Double = 2 + let mockFileTestnetDecimalBalance: Double = 1 let filecoinTestnetDecimals = Int(BraveWallet.NetworkInfo.mockFilecoinTestnet.decimals) let mockFilTestnetDecimalBalanceInWei = formatter.weiString(from: "\(mockFileTestnetDecimalBalance)", radix: .decimal, decimals: filecoinTestnetDecimals) ?? "" @@ -424,7 +454,7 @@ class AccountActivityStoreTests: XCTestCase { mockFilTestnetBalance: mockFilTestnetDecimalBalanceInWei, transactions: [transaction, transactionCopy].enumerated().map { (index, tx) in // transactions sorted by created time, make sure they are in-order - tx.createdTime = firstTransactionDate.addingTimeInterval(TimeInterval(index * 10)) + tx.createdTime = firstTransactionDate.addingTimeInterval(TimeInterval(index) * 1.days) return tx } ) @@ -461,13 +491,12 @@ class AccountActivityStoreTests: XCTestCase { userAssetManager: mockAssetManager ) - let userAssetsExpectation = expectation(description: "accountActivityStore-assetStores") + let userAssetsExpectation = expectation(description: "accountActivityStore-userAssets") accountActivityStore.$userAssets .dropFirst() - .collect(2) + .collect(3) .sink { userAssets in defer { userAssetsExpectation.fulfill() } - XCTAssertEqual(userAssets.count, 2) // empty assets, populated assets guard let lastUpdatedAssets = userAssets.last else { XCTFail("Unexpected test result") return @@ -486,16 +515,25 @@ class AccountActivityStoreTests: XCTestCase { } .store(in: &cancellables) - let transactionSummariesExpectation = expectation(description: "accountActivityStore-transactions") - XCTAssertTrue(accountActivityStore.transactionSummaries.isEmpty) - accountActivityStore.$transactionSummaries + let transactionSectionsExpectation = expectation(description: "accountActivityStore-transactions") + XCTAssertTrue(accountActivityStore.transactionSections.isEmpty) + accountActivityStore.$transactionSections .dropFirst() - .sink { transactionSummaries in - defer { transactionSummariesExpectation.fulfill() } - // summaries are tested in `TransactionParserTests`, just verify they are populated with correct tx - XCTAssertEqual(transactionSummaries.count, 1) // // should not have `transactionCopy` since it's on testnet but the account is on mainnet - XCTAssertEqual(transactionSummaries[safe: 0]?.txInfo, transaction) - XCTAssertEqual(transactionSummaries[safe: 0]?.txInfo.chainId, transaction.chainId) + .collect(3) + .sink { transactionSectionsCollected in + defer { transactionSectionsExpectation.fulfill() } + guard let transactionSections = transactionSectionsCollected.last else { + XCTFail("Unexpected test result") + return + } + // `ParsedTransaction`s are tested in `TransactionParserTests`, + // just verify they are populated with correct tx + XCTAssertEqual(transactionSections.count, 1) + let firstSectionTxs = transactionSections[safe: 0]?.transactions ?? [] + // should not have `transactionCopy` since it's on testnet but the account is on mainnet + XCTAssertEqual(firstSectionTxs.count, 1) + XCTAssertEqual(firstSectionTxs[safe: 0]?.transaction, transaction) + XCTAssertEqual(firstSectionTxs[safe: 0]?.transaction.chainId, transaction.chainId) }.store(in: &cancellables) accountActivityStore.update() @@ -504,4 +542,9 @@ class AccountActivityStoreTests: XCTestCase { XCTAssertNil(error) } } + + override class func tearDown() { + super.tearDown() + Preferences.Wallet.showTestNetworks.reset() + } }