From 551094640404eeecff5fda877254b4cfb3d12f3e Mon Sep 17 00:00:00 2001 From: StephenHeaps <5314553+StephenHeaps@users.noreply.github.com> Date: Thu, 16 Nov 2023 09:05:42 -0500 Subject: [PATCH] Fix brave/brave-ios#8349: Transaction Activity v2 UI (brave/brave-ios#8390) * Transaction Activity tab v2 UI * Resolve `TransactionParserTests`, update `TransactionActivityStoreTests` for date grouping --- Sources/BraveUI/Buttons/LoaderView.swift | 5 + .../BraveWallet/Crypto/AssetIconView.swift | 170 ++++- .../Stores/TransactionsActivityStore.swift | 74 +- .../SwapTransactionConfirmationView.swift | 2 +- .../Transactions/TransactionParser.swift | 75 +- .../TransactionSummaryViews.swift | 665 ++++++++++++++++++ .../Transactions/TransactionsListView.swift | 181 +++++ .../Crypto/TransactionsActivityView.swift | 73 +- .../Extensions/BraveWalletExtensions.swift | 17 + .../Preview Content/MockContent.swift | 20 + Sources/BraveWallet/WalletStrings.swift | 32 +- .../leo.carat.right.symbolset/Contents.json | 11 + .../Contents.json | 11 + .../Contents.json | 11 + .../TransactionParserTests.swift | 24 +- .../TransactionsActivityStoreTests.swift | 160 +++-- 16 files changed, 1357 insertions(+), 174 deletions(-) create mode 100644 Sources/BraveWallet/Crypto/Transactions/TransactionSummaryViews.swift create mode 100644 Sources/BraveWallet/Crypto/Transactions/TransactionsListView.swift create mode 100644 Sources/DesignSystem/Icons/Symbols.xcassets/leo.carat.right.symbolset/Contents.json create mode 100644 Sources/DesignSystem/Icons/Symbols.xcassets/leo.crypto.wallets.symbolset/Contents.json create mode 100644 Sources/DesignSystem/Icons/Symbols.xcassets/leo.loading.spinner.symbolset/Contents.json diff --git a/Sources/BraveUI/Buttons/LoaderView.swift b/Sources/BraveUI/Buttons/LoaderView.swift index b75eb6c51875..639f8fc153bb 100644 --- a/Sources/BraveUI/Buttons/LoaderView.swift +++ b/Sources/BraveUI/Buttons/LoaderView.swift @@ -21,6 +21,7 @@ private class LoaderLayer: CALayer { public class LoaderView: UIView { /// The size of the indicator public enum Size { + case mini case small case normal case large @@ -30,6 +31,8 @@ public class LoaderView: UIView { fileprivate var size: CGSize { switch self { + case .mini: + return CGSize(width: 8, height: 8) case .small: return CGSize(width: 16, height: 16) case .normal: @@ -41,6 +44,8 @@ public class LoaderView: UIView { fileprivate var lineWidth: CGFloat { switch self { + case .mini: + return 1.0 case .small: return 2.0 case .normal: diff --git a/Sources/BraveWallet/Crypto/AssetIconView.swift b/Sources/BraveWallet/Crypto/AssetIconView.swift index 529f4bdbb0b0..58501f75a259 100644 --- a/Sources/BraveWallet/Crypto/AssetIconView.swift +++ b/Sources/BraveWallet/Crypto/AssetIconView.swift @@ -6,6 +6,7 @@ import SwiftUI import BraveCore import BraveUI +import DesignSystem /// Displays an asset's icon from the token registry /// @@ -36,26 +37,10 @@ struct AssetIconView: View { ) } - private var localImage: Image? { - if network.isNativeAsset(token), let uiImage = network.nativeTokenLogoImage { - return Image(uiImage: uiImage) - } - - for logo in [token.logo, token.symbol.lowercased()] { - if let baseURL = BraveWallet.TokenRegistryUtils.tokenLogoBaseURL, - case let imageURL = baseURL.appendingPathComponent(logo), - let image = UIImage(contentsOfFile: imageURL.path) { - return Image(uiImage: image) - } - } - - return nil - } - var body: some View { Group { - if let image = localImage { - image + if let uiImage = token.localImage(network: network) { + Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fit) } else if let url = URL(string: token.logo) { @@ -175,3 +160,152 @@ struct NFTIconView: View { .accessibilityHidden(true) } } + +/// Displays 2 asset icons stacked on top of one another, with the token's network logo on top. +/// `bottomToken` is displayed in the top-right, `topToken` is displayed in the bottom-left, +/// and the network logo is displayed in the bottom-right. +struct StackedAssetIconsView: View { + + var bottomToken: BraveWallet.BlockchainToken? + var topToken: BraveWallet.BlockchainToken? + var network: BraveWallet.NetworkInfo + + /// Length of entire view + @ScaledMetric var length: CGFloat = 40 + /// Max length of entire view + var maxLength: CGFloat? + @ScaledMetric var networkSymbolLength: CGFloat = 15 + var maxNetworkSymbolLength: CGFloat? + + /// Size of padding applied to bottom/top icon so they can overlap + private var iconPadding: CGFloat { + length / 5 + } + + /// Size of asset icon + private var assetIconLength: CGFloat { + length - iconPadding + } + + /// Max size of asset icon + private var maxAssetIconLength: CGFloat? { + guard let maxLength else { return nil } + return maxLength - iconPadding + } + + var body: some View { + ZStack { + Group { + if let bottomToken { + AssetIconView( + token: bottomToken, + network: network, + shouldShowNetworkIcon: false, + length: assetIconLength, + maxLength: maxAssetIconLength + ) + } else { // nil token possible for Solana Swaps + GenericAssetIconView( + backgroundColor: Color(braveSystemName: .gray40), + iconColor: Color.white, + length: assetIconLength, + maxLength: maxAssetIconLength + ) + } + } + .padding(.leading, iconPadding) + .padding(.bottom, iconPadding) + .zIndex(0) + + Group { + if let topToken { + AssetIconView( + token: topToken, + network: network, + shouldShowNetworkIcon: false, + length: assetIconLength, + maxLength: maxAssetIconLength + ) + } else { // nil token possible for Solana Swaps + GenericAssetIconView( + backgroundColor: Color(braveSystemName: .gray20), + iconColor: Color.black, + length: assetIconLength, + maxLength: maxAssetIconLength + ) + } + } + .padding(.top, iconPadding) + .padding(.trailing, iconPadding) + .zIndex(1) + + if let networkLogoImage = network.networkLogoImage { + Group { + Image(uiImage: networkLogoImage) + .resizable() + .overlay( + Circle() + .stroke(lineWidth: 2) + .foregroundColor(Color(braveSystemName: .containerBackground)) + ) + .frame( + width: min(networkSymbolLength, maxNetworkSymbolLength ?? networkSymbolLength), + height: min(networkSymbolLength, maxNetworkSymbolLength ?? networkSymbolLength) + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + .zIndex(2) + } + } + .frame( + width: min(length, maxLength ?? length), + height: min(length, maxLength ?? length) + ) + } +} + +#if DEBUG +struct StackedAssetIconsView_Previews: PreviewProvider { + static var previews: some View { + StackedAssetIconsView( + bottomToken: nil, + topToken: nil, + network: .mockSolana + ) + .previewLayout(.sizeThatFits) + } +} +#endif + +struct GenericAssetIconView: View { + + let backgroundColor: Color + let iconColor: Color + @ScaledMetric var length: CGFloat + let maxLength: CGFloat? + + init( + backgroundColor: Color = Color(braveSystemName: .gray20), + iconColor: Color = Color.black, + length: CGFloat = 40, + maxLength: CGFloat? = nil + ) { + self.backgroundColor = backgroundColor + self.iconColor = iconColor + self._length = .init(wrappedValue: length) + self.maxLength = maxLength + } + + var body: some View { + Circle() + .fill(backgroundColor) + .frame(width: min(length, maxLength ?? length), height: min(length, maxLength ?? length)) + .overlay { + Image(braveSystemName: "leo.crypto.wallets") + .resizable() + .aspectRatio(contentMode: .fit) + .padding(6) + .foregroundColor(iconColor) + } + } +} diff --git a/Sources/BraveWallet/Crypto/Stores/TransactionsActivityStore.swift b/Sources/BraveWallet/Crypto/Stores/TransactionsActivityStore.swift index 6d565eb72ef7..4de16fa9afb4 100644 --- a/Sources/BraveWallet/Crypto/Stores/TransactionsActivityStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/TransactionsActivityStore.swift @@ -7,18 +7,22 @@ import BraveCore import SwiftUI class TransactionsActivityStore: ObservableObject, WalletObserverStore { - @Published var transactionSummaries: [TransactionSummary] = [] - - @Published private(set) var currencyCode: String = CurrencyCode.usd.code { + /// Sections of transactions for display. Each section represents one date. + @Published var transactionSections: [TransactionSection] = [] + /// Filter query to filter the transactions by. + @Published var query: String = "" + /// Selected networks to show transactions for. + @Published var networkFilters: [Selectable] = [] { didSet { - currencyFormatter.currencyCode = currencyCode - guard oldValue != currencyCode else { return } + guard !oldValue.isEmpty else { return } // initial assignment to `networkFilters` update() } } - @Published var networkFilters: [Selectable] = [] { + + @Published private(set) var currencyCode: String = CurrencyCode.usd.code { didSet { - guard !oldValue.isEmpty else { return } // initial assignment to `networkFilters` + currencyFormatter.currencyCode = currencyCode + guard oldValue != currencyCode else { return } update() } } @@ -143,7 +147,7 @@ class TransactionsActivityStore: ObservableObject, WalletObserverStore { guard !Task.isCancelled else { return } // display transactions prior to network request to fetch // estimated solana tx fees & asset prices - self.transactionSummaries = self.transactionSummaries( + self.transactionSections = buildTransactionSections( transactions: allTransactions, networksForCoin: networksForCoin, accountInfos: allAccountInfos, @@ -152,7 +156,7 @@ class TransactionsActivityStore: ObservableObject, WalletObserverStore { assetRatios: assetPricesCache, solEstimatedTxFees: solEstimatedTxFeesCache ) - guard !self.transactionSummaries.isEmpty else { return } + guard !self.transactionSections.isEmpty else { return } if allTransactions.contains(where: { $0.coin == .sol }) { let solTransactions = allTransactions.filter { $0.coin == .sol } @@ -163,7 +167,7 @@ class TransactionsActivityStore: ObservableObject, WalletObserverStore { await updateAssetPricesCache(assetRatioIds: allUserAssetsAssetRatioIds) guard !Task.isCancelled else { return } - self.transactionSummaries = self.transactionSummaries( + self.transactionSections = buildTransactionSections( transactions: allTransactions, networksForCoin: networksForCoin, accountInfos: allAccountInfos, @@ -175,7 +179,7 @@ class TransactionsActivityStore: ObservableObject, WalletObserverStore { } } - private func transactionSummaries( + private func buildTransactionSections( transactions: [BraveWallet.TransactionInfo], networksForCoin: [BraveWallet.CoinType: [BraveWallet.NetworkInfo]], accountInfos: [BraveWallet.AccountInfo], @@ -183,22 +187,40 @@ class TransactionsActivityStore: ObservableObject, WalletObserverStore { allTokens: [BraveWallet.BlockchainToken], assetRatios: [String: Double], solEstimatedTxFees: [String: UInt64] - ) -> [TransactionSummary] { - transactions.compactMap { transaction in - guard let networks = networksForCoin[transaction.coin], let network = networks.first(where: { $0.chainId == transaction.chainId }) else { - return nil - } - return TransactionParser.transactionSummary( - from: transaction, - network: network, - accountInfos: accountInfos, - userAssets: userAssets, - allTokens: allTokens, - assetRatios: assetRatios, - solEstimatedTxFee: solEstimatedTxFees[transaction.id], - currencyFormatter: currencyFormatter + ) -> [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 + } + // 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, + assetRatios: assetRatios, + solEstimatedTxFee: solEstimatedTxFees[transaction.id], + currencyFormatter: currencyFormatter, + decimalFormatStyle: .decimals(precision: 4) + ) + } + return TransactionSection( + date: date, + transactions: parsedTransactions ) - }.sorted(by: { $0.createdTime > $1.createdTime }) + } } @MainActor private func updateSolEstimatedTxFeesCache(_ solTransactions: [BraveWallet.TransactionInfo]) async { diff --git a/Sources/BraveWallet/Crypto/Transaction Confirmations/SwapTransactionConfirmationView.swift b/Sources/BraveWallet/Crypto/Transaction Confirmations/SwapTransactionConfirmationView.swift index a108a03299f9..52b405217fcd 100644 --- a/Sources/BraveWallet/Crypto/Transaction Confirmations/SwapTransactionConfirmationView.swift +++ b/Sources/BraveWallet/Crypto/Transaction Confirmations/SwapTransactionConfirmationView.swift @@ -266,7 +266,7 @@ struct SwapTransactionConfirmationView_Previews: PreviewProvider { fromAddress: BraveWallet.AccountInfo.previewAccount.address, namedToAddress: "0x Exchange", toAddress: "0x1111111111222222222233333333334444444444", - networkSymbol: "ETH", + network: .mockMainnet, details: .ethSwap(.init( fromToken: .mockUSDCToken, fromValue: "1.000004", diff --git a/Sources/BraveWallet/Crypto/Transactions/TransactionParser.swift b/Sources/BraveWallet/Crypto/Transactions/TransactionParser.swift index d3c54e9d45bb..41cc81f4ee31 100644 --- a/Sources/BraveWallet/Crypto/Transactions/TransactionParser.swift +++ b/Sources/BraveWallet/Crypto/Transactions/TransactionParser.swift @@ -140,7 +140,7 @@ enum TransactionParser { fromAddress: transaction.fromAccountId.address, namedToAddress: NamedAddresses.name(for: filTxData.to, accounts: accountInfos), toAddress: filTxData.to, - networkSymbol: network.symbol, + network: network, details: .filSend( .init( sendToken: network.nativeToken, @@ -178,7 +178,7 @@ enum TransactionParser { fromAddress: transaction.fromAccountId.address, namedToAddress: NamedAddresses.name(for: transaction.ethTxToAddress, accounts: accountInfos), toAddress: transaction.ethTxToAddress, - networkSymbol: network.symbol, + network: network, details: .ethSend( .init( fromToken: network.nativeToken, @@ -223,7 +223,7 @@ enum TransactionParser { fromAddress: transaction.fromAccountId.address, namedToAddress: NamedAddresses.name(for: toAddress, accounts: accountInfos), toAddress: toAddress, - networkSymbol: network.symbol, + network: network, details: .erc20Transfer( .init( fromToken: fromToken, @@ -278,7 +278,7 @@ enum TransactionParser { fromAddress: transaction.fromAccountId.address, namedToAddress: NamedAddresses.name(for: transaction.ethTxToAddress, accounts: accountInfos), toAddress: transaction.ethTxToAddress, - networkSymbol: network.symbol, + network: network, details: .ethSwap( .init( fromToken: fromToken, @@ -307,10 +307,13 @@ enum TransactionParser { let token = token(for: contractAddress, network: network, userAssets: userAssets, allTokens: allTokens) let isUnlimited = value.caseInsensitiveCompare(WalletConstants.MAX_UINT256) == .orderedSame let approvalAmount: String + let approvalFiat: String if isUnlimited { approvalAmount = Strings.Wallet.editPermissionsApproveUnlimited + approvalFiat = Strings.Wallet.editPermissionsApproveUnlimited } else { approvalAmount = formatter.decimalString(for: value.removingHexPrefix, radix: .hex, decimals: Int(token?.decimals ?? network.decimals))?.trimmingTrailingZeros ?? "" + approvalFiat = currencyFormatter.string(from: NSNumber(value: assetRatios[token?.assetRatioId.lowercased() ?? "", default: 0] * (Double(approvalAmount) ?? 0))) ?? "$0.00" } /* Example: Approve DAI @@ -329,13 +332,14 @@ enum TransactionParser { fromAddress: transaction.fromAccountId.address, namedToAddress: NamedAddresses.name(for: transaction.ethTxToAddress, accounts: accountInfos), toAddress: transaction.ethTxToAddress, - networkSymbol: network.symbol, + network: network, details: .ethErc20Approve( .init( token: token, tokenContractAddress: contractAddress, approvalValue: value, approvalAmount: approvalAmount, + approvalFiat: approvalFiat, isUnlimited: isUnlimited, spenderAddress: spenderAddress, gasFee: gasFee( @@ -362,7 +366,7 @@ enum TransactionParser { fromAddress: transaction.fromAccountId.address, // The caller, which may not be the owner namedToAddress: NamedAddresses.name(for: toAddress, accounts: accountInfos), toAddress: toAddress, - networkSymbol: network.symbol, + network: network, details: .erc721Transfer( .init( fromToken: token, @@ -402,7 +406,7 @@ enum TransactionParser { fromAddress: transaction.fromAccountId.address, namedToAddress: NamedAddresses.name(for: toAddress, accounts: accountInfos), toAddress: toAddress, - networkSymbol: network.symbol, + network: network, details: .solSystemTransfer( .init( fromToken: network.nativeToken, @@ -453,7 +457,7 @@ enum TransactionParser { fromAddress: transaction.fromAccountId.address, namedToAddress: NamedAddresses.name(for: toAddress, accounts: accountInfos), toAddress: toAddress, - networkSymbol: network.symbol, + network: network, details: .solSplTokenTransfer( .init( fromToken: fromToken, @@ -564,7 +568,7 @@ enum TransactionParser { fromAddress: transaction.fromAccountId.address, namedToAddress: NamedAddresses.name(for: toAddress ?? "", accounts: accountInfos), toAddress: toAddress ?? "", - networkSymbol: network.symbol, + network: network, details: details ) case .erc1155SafeTransferFrom: @@ -669,8 +673,10 @@ struct ParsedTransaction: Equatable { /// Address sending to let toAddress: String + /// Network of the transaction + let network: BraveWallet.NetworkInfo /// Network symbol of the transaction - let networkSymbol: String + var networkSymbol: String { network.symbol } /// Details of the transaction let details: Details @@ -703,7 +709,7 @@ struct ParsedTransaction: Equatable { self.fromAddress = "" self.namedToAddress = "" self.toAddress = "" - self.networkSymbol = "" + self.network = .init() self.details = .other } @@ -713,7 +719,7 @@ struct ParsedTransaction: Equatable { fromAddress: String, namedToAddress: String, toAddress: String, - networkSymbol: String, + network: BraveWallet.NetworkInfo, details: Details ) { self.transaction = transaction @@ -721,9 +727,50 @@ struct ParsedTransaction: Equatable { self.fromAddress = fromAddress self.namedToAddress = namedToAddress self.toAddress = toAddress - self.networkSymbol = networkSymbol + self.network = network self.details = details } + + /// Determines if the given query matches the `ParsedTransaction`. + func matches(_ query: String) -> Bool { + if namedFromAddress.localizedCaseInsensitiveContains(query) || + fromAddress.localizedCaseInsensitiveContains(query) || + namedToAddress.localizedCaseInsensitiveContains(query) || + toAddress.localizedCaseInsensitiveContains(query) || + transaction.txHash.localizedCaseInsensitiveContains(query) { + return true + } + switch details { + case .ethSend(let sendDetails), + .erc20Transfer(let sendDetails), + .solSystemTransfer(let sendDetails), + .solSplTokenTransfer(let sendDetails): + return sendDetails.fromToken?.matches(query) == true + case .ethSwap(let ethSwapDetails): + return ethSwapDetails.fromToken?.matches(query) == true || + ethSwapDetails.toToken?.matches(query) == true + case .ethErc20Approve(let ethErc20ApproveDetails): + return ethErc20ApproveDetails.token?.matches(query) == true + case .erc721Transfer(let eth721TransferDetails): + return eth721TransferDetails.fromToken?.matches(query) == true + case .solDappTransaction(let solanaTxDetails), + .solSwapTransaction(let solanaTxDetails): + return solanaTxDetails.symbol?.localizedCaseInsensitiveContains(query) == true + case .filSend(let filSendDetails): + return filSendDetails.sendToken?.matches(query) == true + case .other: + return false + } + } +} + +private extension BraveWallet.BlockchainToken { + /// Determines if the given query matches the `BlockchainToken`. + func matches(_ query: String) -> Bool { + name.localizedCaseInsensitiveContains(query) == true || + symbol.localizedCaseInsensitiveContains(query) == true || + contractAddress.localizedCaseInsensitiveContains(query) == true + } } struct EthErc20ApproveDetails: Equatable { @@ -735,6 +782,8 @@ struct EthErc20ApproveDetails: Equatable { let approvalValue: String /// Value being approved formatted let approvalAmount: String + /// The amount approved formatted as currency + let approvalFiat: String /// If the value being approved is unlimited let isUnlimited: Bool /// The spender address to get the current allowance diff --git a/Sources/BraveWallet/Crypto/Transactions/TransactionSummaryViews.swift b/Sources/BraveWallet/Crypto/Transactions/TransactionSummaryViews.swift new file mode 100644 index 000000000000..ab4d607ddea1 --- /dev/null +++ b/Sources/BraveWallet/Crypto/Transactions/TransactionSummaryViews.swift @@ -0,0 +1,665 @@ +/* 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 + +struct TransactionSummaryViewContainer: View { + + let parsedTransaction: ParsedTransaction + + var body: some View { + switch parsedTransaction.details { + case .ethSend(let details), + .erc20Transfer(let details), + .solSystemTransfer(let details), + .solSplTokenTransfer(let details): + SendTransactionSummaryView( + sentFromAccountName: parsedTransaction.namedFromAddress, + token: details.fromToken, + network: parsedTransaction.network, + valueSent: details.fromAmount, + fiatValueSent: details.fromFiat ?? "", + status: parsedTransaction.transaction.txStatus, + time: parsedTransaction.transaction.createdTime + ) + case .filSend(let details): + SendTransactionSummaryView( + sentFromAccountName: parsedTransaction.namedFromAddress, + token: details.sendToken, + network: parsedTransaction.network, + valueSent: details.sendAmount, + fiatValueSent: details.sendFiat ?? "", + status: parsedTransaction.transaction.txStatus, + time: parsedTransaction.transaction.createdTime + ) + case .ethSwap(let details): + SwapTransactionSummaryView( + swappedOnAccountName: parsedTransaction.namedFromAddress, + fromToken: details.fromToken, + toToken: details.toToken, + network: parsedTransaction.network, + fromValue: details.fromAmount, + toValue: details.minBuyAmount, + status: parsedTransaction.transaction.txStatus, + time: parsedTransaction.transaction.createdTime + ) + case .solSwapTransaction: + SolanaSwapTransactionSummaryView( + swappedOnAccountName: parsedTransaction.namedFromAddress, + network: parsedTransaction.network, + status: parsedTransaction.transaction.txStatus, + time: parsedTransaction.transaction.createdTime + ) + case .ethErc20Approve(let details): + ApprovalTransactionSummaryView( + fromAccountName: parsedTransaction.namedFromAddress, + token: details.token, + network: parsedTransaction.network, + valueApproved: details.approvalAmount, + fiatValueApproved: details.approvalFiat, + status: parsedTransaction.transaction.txStatus, + time: parsedTransaction.transaction.createdTime + ) + case .erc721Transfer(let details): + SendTransactionSummaryView( + sentFromAccountName: parsedTransaction.namedFromAddress, + token: details.fromToken, + network: parsedTransaction.network, + valueSent: nil, + fiatValueSent: nil, + status: parsedTransaction.transaction.txStatus, + time: parsedTransaction.transaction.createdTime + ) + case .solDappTransaction: + SendTransactionSummaryView( + sentFromAccountName: parsedTransaction.namedFromAddress, + token: parsedTransaction.network.nativeToken, + network: parsedTransaction.network, + valueSent: nil, + fiatValueSent: nil, + status: parsedTransaction.transaction.txStatus, + time: parsedTransaction.transaction.createdTime + ) + case .other: + SendTransactionSummaryView( + sentFromAccountName: parsedTransaction.namedFromAddress, + token: nil, + network: parsedTransaction.network, + valueSent: nil, + fiatValueSent: nil, + status: parsedTransaction.transaction.txStatus, + time: parsedTransaction.transaction.createdTime + ) + } + } +} + +struct SendTransactionSummaryView: View { + + let sentFromAccountName: String + let token: BraveWallet.BlockchainToken? + let network: BraveWallet.NetworkInfo + let valueSent: String? + let fiatValueSent: String? + let status: BraveWallet.TransactionStatus + let time: Date + + init( + sentFromAccountName: String, + token: BraveWallet.BlockchainToken?, + network: BraveWallet.NetworkInfo, + valueSent: String?, + fiatValueSent: String?, + status: BraveWallet.TransactionStatus, + time: Date + ) { + self.sentFromAccountName = sentFromAccountName + self.token = token + self.network = network + self.valueSent = valueSent + self.fiatValueSent = fiatValueSent + self.status = status + self.time = time + } + + private let primaryFont: Font = .callout.weight(.semibold) + private let primaryTextColor = Color(braveSystemName: .textPrimary) + private let secondaryFont: Font = .footnote + private let secondaryTextColor = Color(braveSystemName: .textTertiary) + + @ScaledMetric private var length: CGFloat = 32 + private let maxLength: CGFloat = 48 + @ScaledMetric private var networkSymbolLength: CGFloat = 15 + private let maxNetworkSymbolLength: CGFloat = 30 + + private var sendTitle: String { + String.localizedStringWithFormat( + Strings.Wallet.transactionSummaryIntentLabel, + Strings.Wallet.sent + ) + } + + var body: some View { + VStack { + HStack { // header + Text(time, style: .time) + Image(braveSystemName: "leo.send") + .imageScale(.small) + .foregroundColor(Color(braveSystemName: .iconDefault)) + Text(sendTitle) + Text(" " + sentFromAccountName).bold() + } + .font(secondaryFont) + .foregroundColor(secondaryTextColor) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + Group { + if let token { + if token.isNft || token.isErc721 { + NFTIconView( + token: token, + network: network, + url: nil, + shouldShowNetworkIcon: true, + length: length, + maxLength: maxLength, + tokenLogoLength: networkSymbolLength, + maxTokenLogoLength: maxNetworkSymbolLength + ) + } else { + AssetIconView( + token: token, + network: network, + shouldShowNetworkIcon: true, + length: length, + maxLength: maxLength, + networkSymbolLength: networkSymbolLength, + maxNetworkSymbolLength: maxNetworkSymbolLength + ) + } + } else { + GenericAssetIconView( + length: length, + maxLength: maxLength + ) + } + } + .overlay(alignment: .topLeading) { + if status.shouldShowTransactionStatus { + TransactionStatusBubble(status: status) + } + } + VStack(alignment: .leading) { + Text(token?.name ?? "") + .font(primaryFont) + .foregroundColor(primaryTextColor) + Text(token?.symbol ?? "") + .font(secondaryFont) + .foregroundColor(secondaryTextColor) + } + + Spacer() + + if let valueSent { + VStack(alignment: .trailing) { + Group { + if let symbol = token?.symbol { + Text("-\(valueSent) \(symbol)") + .font(primaryFont) + .foregroundColor(primaryTextColor) + } else { + Text("-\(valueSent)") + } + } + .font(primaryFont) + .foregroundColor(primaryTextColor) + Text(fiatValueSent ?? "") + .font(secondaryFont) + .foregroundColor(secondaryTextColor) + } + .multilineTextAlignment(.trailing) + } + } + } + .multilineTextAlignment(.leading) + .padding(8) + .frame(maxWidth: .infinity) + } +} + +struct SwapTransactionSummaryView: View { + + let swappedOnAccountName: String + let fromToken: BraveWallet.BlockchainToken? + let toToken: BraveWallet.BlockchainToken? + let network: BraveWallet.NetworkInfo + let fromValue: String + let toValue: String + let status: BraveWallet.TransactionStatus + let time: Date + + init( + swappedOnAccountName: String, + fromToken: BraveWallet.BlockchainToken?, + toToken: BraveWallet.BlockchainToken?, + network: BraveWallet.NetworkInfo, + fromValue: String, + toValue: String, + status: BraveWallet.TransactionStatus, + time: Date + ) { + self.swappedOnAccountName = swappedOnAccountName + self.fromToken = fromToken + self.toToken = toToken + self.network = network + self.fromValue = fromValue + self.toValue = toValue + self.status = status + self.time = time + } + + private let primaryFont: Font = .callout.weight(.semibold) + private let primaryTextColor = Color(braveSystemName: .textPrimary) + private let secondaryFont: Font = .footnote + private let secondaryTextColor = Color(braveSystemName: .textTertiary) + + @ScaledMetric private var length: CGFloat = 32 + private let maxLength: CGFloat = 48 + @ScaledMetric private var networkSymbolLength: CGFloat = 15 + private let maxNetworkSymbolLength: CGFloat = 30 + + var body: some View { + VStack { + HStack { // header + Text(time, style: .time) + Image(braveSystemName: "leo.currency.exchange") + .imageScale(.small) + .foregroundColor(Color(braveSystemName: .iconDefault)) + Text(Strings.Wallet.transactionSummarySwapOn) + Text(" " + swappedOnAccountName).bold() + } + .font(secondaryFont) + .foregroundColor(secondaryTextColor) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + StackedAssetIconsView( + bottomToken: fromToken, + topToken: toToken, + network: network, + length: length, + maxLength: maxLength, + networkSymbolLength: networkSymbolLength, + maxNetworkSymbolLength: maxNetworkSymbolLength + ) + .overlay(alignment: .topLeading) { + if status.shouldShowTransactionStatus { + TransactionStatusBubble(status: status) + } + } + HStack { + Text(fromToken?.symbol ?? "") + .font(primaryFont) + Image(braveSystemName: "leo.carat.right") + .resizable() + .aspectRatio(contentMode: .fit) + .padding(4) + .frame(width: 16, height: 16) + .foregroundColor(Color(braveSystemName: .iconDefault)) + .background(Color(braveSystemName: .containerHighlight).clipShape(Circle())) + Text(toToken?.symbol ?? "") + .font(primaryFont) + } + .foregroundColor(primaryTextColor) + + Spacer() + + VStack(alignment: .trailing) { + Text("-\(fromValue) \(fromToken?.symbol ?? "")") + .font(secondaryFont) + .foregroundColor(secondaryTextColor) + Text("+\(toValue) \(toToken?.symbol ?? "")") + .font(primaryFont) + .foregroundColor(primaryTextColor) + } + .multilineTextAlignment(.trailing) + } + } + .multilineTextAlignment(.leading) + .padding(8) + .frame(maxWidth: .infinity) + } +} + +struct SolanaSwapTransactionSummaryView: View { + + let swappedOnAccountName: String + let network: BraveWallet.NetworkInfo + let status: BraveWallet.TransactionStatus + let time: Date + + init( + swappedOnAccountName: String, + network: BraveWallet.NetworkInfo, + status: BraveWallet.TransactionStatus, + time: Date + ) { + self.swappedOnAccountName = swappedOnAccountName + self.network = network + self.status = status + self.time = time + } + + private let primaryFont: Font = .callout.weight(.semibold) + private let primaryTextColor = Color(braveSystemName: .textPrimary) + private let secondaryFont: Font = .footnote + private let secondaryTextColor = Color(braveSystemName: .textTertiary) + + @ScaledMetric private var length: CGFloat = 32 + private let maxLength: CGFloat = 48 + @ScaledMetric private var networkSymbolLength: CGFloat = 15 + private let maxNetworkSymbolLength: CGFloat = 30 + + var body: some View { + VStack { + HStack { // header + Text(time, style: .time) + Image(braveSystemName: "leo.currency.exchange") + .imageScale(.small) + .foregroundColor(Color(braveSystemName: .iconDefault)) + Text(Strings.Wallet.transactionSummarySwapOn) + Text(" " + swappedOnAccountName).bold() + } + .font(secondaryFont) + .foregroundColor(secondaryTextColor) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + StackedAssetIconsView( + bottomToken: nil, + topToken: nil, + network: network, + length: length, + maxLength: maxLength, + networkSymbolLength: networkSymbolLength, + maxNetworkSymbolLength: maxNetworkSymbolLength + ) + .overlay(alignment: .topLeading) { + if status.shouldShowTransactionStatus { + TransactionStatusBubble(status: status) + } + } + HStack { + Text(Strings.Wallet.transactionSummarySolanaSwap) + .font(primaryFont) + } + .foregroundColor(primaryTextColor) + + Spacer() + } + } + .multilineTextAlignment(.leading) + .padding(.horizontal, 8) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + } +} + +struct ApprovalTransactionSummaryView: View { + + let fromAccountName: String + let token: BraveWallet.BlockchainToken? + let network: BraveWallet.NetworkInfo + let valueApproved: String + let fiatValueApproved: String + let status: BraveWallet.TransactionStatus + let time: Date + + init( + fromAccountName: String, + token: BraveWallet.BlockchainToken?, + network: BraveWallet.NetworkInfo, + valueApproved: String, + fiatValueApproved: String, + status: BraveWallet.TransactionStatus, + time: Date + ) { + self.fromAccountName = fromAccountName + self.token = token + self.network = network + self.valueApproved = valueApproved + self.fiatValueApproved = fiatValueApproved + self.status = status + self.time = time + } + + private let primaryFont: Font = .callout.weight(.semibold) + private let primaryTextColor = Color(braveSystemName: .textPrimary) + private let secondaryFont: Font = .footnote + private let secondaryTextColor = Color(braveSystemName: .textTertiary) + + @ScaledMetric private var length: CGFloat = 32 + private let maxLength: CGFloat = 48 + @ScaledMetric private var networkSymbolLength: CGFloat = 15 + private let maxNetworkSymbolLength: CGFloat = 30 + + private var approvedTitle: String { + String.localizedStringWithFormat( + Strings.Wallet.transactionSummaryIntentLabel, + Strings.Wallet.transactionTypeApprove + ) + } + + var body: some View { + VStack { + HStack { // header + Text(time, style: .time) + Image(braveSystemName: "leo.check.normal") + .imageScale(.small) + .foregroundColor(Color(braveSystemName: .iconDefault)) + Text(approvedTitle) + Text(" " + fromAccountName).bold() + } + .font(secondaryFont) + .foregroundColor(secondaryTextColor) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + Group { + if let token { + AssetIconView( + token: token, + network: network, + shouldShowNetworkIcon: true, + length: length, + maxLength: maxLength, + networkSymbolLength: networkSymbolLength, + maxNetworkSymbolLength: maxNetworkSymbolLength + ) + } else { + GenericAssetIconView( + length: length, + maxLength: maxLength + ) + } + } + .overlay(alignment: .topLeading) { + if status.shouldShowTransactionStatus { + TransactionStatusBubble(status: status) + } + } + VStack(alignment: .leading) { + Text(token?.name ?? "") + .font(primaryFont) + .foregroundColor(primaryTextColor) + Text(token?.symbol ?? "") + .font(secondaryFont) + .foregroundColor(secondaryTextColor) + } + + Spacer() + + VStack(alignment: .trailing) { + Text(valueApproved) + .font(primaryFont) + .foregroundColor(primaryTextColor) + Text(fiatValueApproved) + .font(secondaryFont) + .foregroundColor(secondaryTextColor) + } + .multilineTextAlignment(.trailing) + } + } + .multilineTextAlignment(.leading) + .padding(8) + .frame(maxWidth: .infinity) + } +} + +#if DEBUG +struct TransactionSummaryRow_Previews: PreviewProvider { + static var previews: some View { + VStack { + Group { + SendTransactionSummaryView( + sentFromAccountName: "Account 1", + token: .mockUSDCToken, + network: .mockMainnet, + valueSent: "37.8065", + fiatValueSent: "$37.80", + status: .unapproved, + time: Date() + ) + Divider() + SendTransactionSummaryView( + sentFromAccountName: "Account 1", + token: .mockERC721NFTToken, + network: .mockMainnet, + valueSent: nil, + fiatValueSent: nil, + status: .submitted, + time: Date() + ) + } + Divider() + Group { + SwapTransactionSummaryView( + swappedOnAccountName: "Account 1", + fromToken: .previewToken, + toToken: .previewDaiToken, + network: .mockMainnet, + fromValue: "0.02", + toValue: "189.301", + status: .confirmed, + time: Date() + ) + Divider() + SolanaSwapTransactionSummaryView( + swappedOnAccountName: "Account 1", + network: .mockMainnet, + status: .error, + time: Date() + ) + } + Divider() + Group { + ApprovalTransactionSummaryView( + fromAccountName: "Account 1", + token: .previewToken, + network: .mockMainnet, + valueApproved: "Unlimited", + fiatValueApproved: "Unlimited", + status: .submitted, + time: Date() + ) + Divider() + ApprovalTransactionSummaryView( + fromAccountName: "Account 1", + token: .mockUSDCToken, + network: .mockMainnet, + valueApproved: "1", + fiatValueApproved: "$1,500", + status: .submitted, + time: Date() + ) + } + } + .previewLayout(.sizeThatFits) + } +} +#endif + +private struct TransactionStatusBubble: View { + + let status: BraveWallet.TransactionStatus + + var body: some View { + Circle() + .fill(status.bubbleBackgroundColor) + .frame(width: 12, height: 12) + .overlay( + Circle() + .stroke(lineWidth: 1) + .foregroundColor(Color(braveSystemName: .containerBackground)) + ) + .overlay { + if status.shouldShowLoadingAnimation { + ProgressView() + .progressViewStyle(.braveCircular(size: .mini)) + } else { + Image(braveSystemName: "leo.loading.spinner") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(Color(braveSystemName: .textDisabled)) + .padding(2) + } + } + } +} + +private extension BraveWallet.TransactionStatus { + /// If we should show transaction status bubble + var shouldShowTransactionStatus: Bool { + switch self { + case .approved, .confirmed, .signed: + return false + default: + return true + } + } + + /// If we should show transaction status as loading + var shouldShowLoadingAnimation: Bool { + switch self { + case .unapproved, .submitted: + return true + default: + return false + } + } + + /// Color of status bubble + var bubbleBackgroundColor: Color { + switch self { + case .confirmed, .approved: + return Color(braveSystemName: .systemfeedbackSuccessBackground) + case .rejected, .error, .dropped: + return Color(braveSystemName: .systemfeedbackErrorIcon) + case .unapproved: + return Color(braveSystemName: .legacyInteractive8) + case .submitted, .signed: + return Color(braveSystemName: .blue40) + @unknown default: + return Color.clear + } + } +} + +#if DEBUG +struct TransactionStatusBubble_Previews: PreviewProvider { + static var previews: some View { + TransactionStatusBubble(status: .unapproved) + .previewLayout(.sizeThatFits) + } +} +#endif diff --git a/Sources/BraveWallet/Crypto/Transactions/TransactionsListView.swift b/Sources/BraveWallet/Crypto/Transactions/TransactionsListView.swift new file mode 100644 index 000000000000..c4a27c771d70 --- /dev/null +++ b/Sources/BraveWallet/Crypto/Transactions/TransactionsListView.swift @@ -0,0 +1,181 @@ +/* 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 + +struct TransactionSection: Equatable, Identifiable { + var id: Date { date } + let date: Date + + let transactions: [ParsedTransaction] +} + +/// List of transactions separated by date with a search bar and filter button. +/// Used in Activity tab, Asset Details (#8137), etc. +struct TransactionsListView: View { + + /// All `TransactionSection` items, unfiltered. + let transactionSections: [TransactionSection] + /// Query displayed in the search bar above the transactions. + @Binding var query: String + /// Called when the filters button beside the search bar is tapped/ + let filtersButtonTapped: () -> Void + /// Called when a transaction is tapped. + let transactionTapped: (BraveWallet.TransactionInfo) -> Void + + /// Returns `transactionSections` filtered using the `filter` value. + var filteredTransactionSections: [TransactionSection] { + if query.isEmpty { + return transactionSections + } + return transactionSections.compactMap { transactionSection in + // check for transactions matching `filter` + let filteredTransactions = transactionSection.transactions.filter { parsedTransaction in + parsedTransaction.matches(query) + } + if filteredTransactions.isEmpty { + // don't return section if no transactions + return nil + } + return TransactionSection(date: transactionSection.date, transactions: filteredTransactions) + } + } + + var body: some View { + ScrollView { + LazyVStack(pinnedViews: [.sectionHeaders]) { // pin search bar + filters + Section(content: { + LazyVStack { // don't pin date headers + if filteredTransactionSections.isEmpty { + emptyState + .listRowBackground(Color.clear) + } else { + ForEach(filteredTransactionSections) { section in + Section(content: { + ForEach(section.transactions, id: \.transaction.id) { parsedTransaction in + Button(action: { + transactionTapped(parsedTransaction.transaction) + }) { + TransactionSummaryViewContainer( + parsedTransaction: parsedTransaction + ) + } + .listRowInsets(.zero) + } + }, header: { + Text(section.date, style: .date) + .font(.subheadline.weight(.semibold)) + .foregroundColor(Color(braveSystemName: .textTertiary)) + .frame(maxWidth: .infinity, alignment: .leading) + }) + } + } + } + .padding(.horizontal) + }, header: { + searchBarAndFiltersContainer + }) + } + } + .background(Color(braveSystemName: .containerBackground)) + } + + private var searchBarAndFiltersContainer: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + SearchBar(text: $query, placeholder: Strings.Wallet.search) + AssetButton(braveSystemName: "leo.filter.settings", action: filtersButtonTapped) + } + .padding(.vertical, 8) + Divider() + } + .padding(.horizontal, 8) + .frame(maxWidth: .infinity) + .background(Color(braveSystemName: .containerBackground)) + } + + private var emptyState: some View { + VStack(alignment: .center, spacing: 10) { + Text(Strings.Wallet.activityPageEmptyTitle) + .font(.headline.weight(.semibold)) + .foregroundColor(Color(.braveLabel)) + Text(Strings.Wallet.activityPageEmptyDescription) + .font(.subheadline.weight(.semibold)) + .foregroundColor(Color(.secondaryLabel)) + } + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + .padding(.horizontal, 32) + } +} + +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 = "" + static var previews: some View { + TransactionsListView( + transactionSections: [ + .init( + date: Date(), + transactions: [ + .previewConfirmedSend, + .previewConfirmedSwap, + .previewConfirmedERC20Approve + ].compactMap { $0 } + ) + ], + query: $query, + filtersButtonTapped: {}, + transactionTapped: { _ in } + ) + } +} +#endif diff --git a/Sources/BraveWallet/Crypto/TransactionsActivityView.swift b/Sources/BraveWallet/Crypto/TransactionsActivityView.swift index b2e4c0cd1040..33559b94804f 100644 --- a/Sources/BraveWallet/Crypto/TransactionsActivityView.swift +++ b/Sources/BraveWallet/Crypto/TransactionsActivityView.swift @@ -14,14 +14,19 @@ struct TransactionsActivityView: View { @State private var isPresentingNetworkFilter = false @State private var transactionDetails: TransactionDetailsStore? - private var networkFilterButton: some View { - Button(action: { - self.isPresentingNetworkFilter = true - }) { - Image(braveSystemName: "leo.tune") - .font(.footnote.weight(.medium)) - .foregroundColor(Color(.braveBlurpleTint)) - .clipShape(Rectangle()) + var body: some View { + TransactionsListView( + transactionSections: store.transactionSections, + query: $store.query, + filtersButtonTapped: { + isPresentingNetworkFilter = true + }, + transactionTapped: { + transactionDetails = store.transactionDetailsStore(for: $0) + } + ) + .onAppear { + store.update() } .sheet(isPresented: $isPresentingNetworkFilter) { NavigationView { @@ -38,41 +43,6 @@ struct TransactionsActivityView: View { networkStore.closeNetworkSelectionStore() } } - } - - var body: some View { - List { - Section { - if store.transactionSummaries.isEmpty { - emptyState - .listRowBackground(Color.clear) - } else { - Group { - ForEach(store.transactionSummaries) { txSummary in - Button(action: { - self.transactionDetails = store.transactionDetailsStore(for: txSummary.txInfo) - }) { - TransactionSummaryView(summary: txSummary) - } - } - } - .listRowBackground(Color(.secondaryBraveGroupedBackground)) - } - } header: { - HStack { - Text(Strings.Wallet.assetsTitle) - Spacer() - networkFilterButton - } - .textCase(nil) - .padding(.horizontal, -8) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .listBackgroundColor(Color(UIColor.braveGroupedBackground)) - .onAppear { - store.update() - } .sheet( isPresented: Binding( get: { self.transactionDetails != nil }, @@ -87,25 +57,10 @@ struct TransactionsActivityView: View { } } } - - private var emptyState: some View { - VStack(alignment: .center, spacing: 10) { - Text(Strings.Wallet.activityPageEmptyTitle) - .font(.headline.weight(.semibold)) - .foregroundColor(Color(.braveLabel)) - Text(Strings.Wallet.activityPageEmptyDescription) - .font(.subheadline.weight(.semibold)) - .foregroundColor(Color(.secondaryLabel)) - } - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding(.vertical, 60) - .padding(.horizontal, 32) - } } #if DEBUG -struct TransactionsActivityViewView_Previews: PreviewProvider { +struct TransactionsActivityView_Previews: PreviewProvider { static var previews: some View { TransactionsActivityView( store: .preview, diff --git a/Sources/BraveWallet/Extensions/BraveWalletExtensions.swift b/Sources/BraveWallet/Extensions/BraveWalletExtensions.swift index ea770f5a7242..db18db2f564a 100644 --- a/Sources/BraveWallet/Extensions/BraveWalletExtensions.swift +++ b/Sources/BraveWallet/Extensions/BraveWalletExtensions.swift @@ -293,6 +293,23 @@ extension BraveWallet.BlockchainToken { return name } } + + /// Returns the local image asset for the `BlockchainToken`. + func localImage(network: BraveWallet.NetworkInfo) -> UIImage? { + if network.isNativeAsset(self), let uiImage = network.nativeTokenLogoImage { + return uiImage + } + + for logo in [logo, symbol.lowercased()] { + if let baseURL = BraveWallet.TokenRegistryUtils.tokenLogoBaseURL, + case let imageURL = baseURL.appendingPathComponent(logo), + let image = UIImage(contentsOfFile: imageURL.path) { + return image + } + } + + return nil + } } extension BraveWallet.OnRampProvider { diff --git a/Sources/BraveWallet/Preview Content/MockContent.swift b/Sources/BraveWallet/Preview Content/MockContent.swift index 34446078d859..a4f2e0919635 100644 --- a/Sources/BraveWallet/Preview Content/MockContent.swift +++ b/Sources/BraveWallet/Preview Content/MockContent.swift @@ -437,6 +437,26 @@ extension TransactionSummary { } } +extension ParsedTransaction { + + static var previewConfirmedSend = previewParsedTransaction(from: .previewConfirmedSend) + static var previewConfirmedSwap = previewParsedTransaction(from: .previewConfirmedSwap) + static var previewConfirmedERC20Approve = previewParsedTransaction(from: .previewConfirmedERC20Approve) + + static func previewParsedTransaction(from txInfo: BraveWallet.TransactionInfo) -> Self? { + TransactionParser.parseTransaction( + transaction: txInfo, + network: .mockMainnet, + accountInfos: [.previewAccount], + userAssets: [.previewToken, .previewDaiToken], + allTokens: [], + assetRatios: [BraveWallet.BlockchainToken.previewToken.assetRatioId.lowercased(): 1], + solEstimatedTxFee: nil, + currencyFormatter: .usdCurrencyFormatter + ) + } +} + extension BraveWallet.CoinMarket { static var mockCoinMarketBitcoin: BraveWallet.CoinMarket { .init( diff --git a/Sources/BraveWallet/WalletStrings.swift b/Sources/BraveWallet/WalletStrings.swift index 4b5ac5a737bf..9abc958af13d 100644 --- a/Sources/BraveWallet/WalletStrings.swift +++ b/Sources/BraveWallet/WalletStrings.swift @@ -1563,8 +1563,8 @@ extension Strings { value: "Approved %@ %@", comment: "The title shown for ERC20 approvals. The first '%@' becomes the amount, the second '%@' becomes the symbol for the cryptocurrency. For example: \"Approved 150.0 BAT\"" ) - public static let transactionUnknownApprovalTitle = NSLocalizedString( - "wallet.transactionUnknownApprovalTitle", + public static let transactionApprovalTitle = NSLocalizedString( + "wallet.transactionApprovalTitle", tableName: "BraveWallet", bundle: .module, value: "Approved", @@ -4752,5 +4752,33 @@ extension Strings { value: "Details", comment: "The title of the details view for Sign In With Ethereum/Brave Wallet requests." ) + public static let transactionSummaryIntentLabel = NSLocalizedString( + "wallet.transactionSummaryIntentLabel", + tableName: "BraveWallet", + bundle: .module, + value: "%@ from", + comment: "The label used to describe a transaction type. Used like 'Send from' or 'Approved from'." + ) + public static let transactionSummarySwapOn = NSLocalizedString( + "wallet.transactionSummarySwapOn", + tableName: "BraveWallet", + bundle: .module, + value: "Swap on", + comment: "The label to describe an Swap transaction." + ) + public static let transactionSummarySolanaSwap = NSLocalizedString( + "wallet.transactionSummarySolanaSwap", + tableName: "BraveWallet", + bundle: .module, + value: "Solana Swap", + comment: "The label to describe an Solana Swap transaction." + ) + public static let search = NSLocalizedString( + "wallet.search", + tableName: "BraveWallet", + bundle: .module, + value: "Search", + comment: "The label as a placeholder in search fields." + ) } } diff --git a/Sources/DesignSystem/Icons/Symbols.xcassets/leo.carat.right.symbolset/Contents.json b/Sources/DesignSystem/Icons/Symbols.xcassets/leo.carat.right.symbolset/Contents.json new file mode 100644 index 000000000000..2f415ce6e4c0 --- /dev/null +++ b/Sources/DesignSystem/Icons/Symbols.xcassets/leo.carat.right.symbolset/Contents.json @@ -0,0 +1,11 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "idiom" : "universal" + } + ] +} diff --git a/Sources/DesignSystem/Icons/Symbols.xcassets/leo.crypto.wallets.symbolset/Contents.json b/Sources/DesignSystem/Icons/Symbols.xcassets/leo.crypto.wallets.symbolset/Contents.json new file mode 100644 index 000000000000..2f415ce6e4c0 --- /dev/null +++ b/Sources/DesignSystem/Icons/Symbols.xcassets/leo.crypto.wallets.symbolset/Contents.json @@ -0,0 +1,11 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "idiom" : "universal" + } + ] +} diff --git a/Sources/DesignSystem/Icons/Symbols.xcassets/leo.loading.spinner.symbolset/Contents.json b/Sources/DesignSystem/Icons/Symbols.xcassets/leo.loading.spinner.symbolset/Contents.json new file mode 100644 index 000000000000..2f415ce6e4c0 --- /dev/null +++ b/Sources/DesignSystem/Icons/Symbols.xcassets/leo.loading.spinner.symbolset/Contents.json @@ -0,0 +1,11 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "idiom" : "universal" + } + ] +} diff --git a/Tests/BraveWalletTests/TransactionParserTests.swift b/Tests/BraveWalletTests/TransactionParserTests.swift index 9f8b6368df36..59933b423851 100644 --- a/Tests/BraveWalletTests/TransactionParserTests.swift +++ b/Tests/BraveWalletTests/TransactionParserTests.swift @@ -124,7 +124,7 @@ class TransactionParserTests: XCTestCase { fromAddress: accountInfos[0].address, namedToAddress: "Ethereum Account 2", toAddress: "0x0987654321098765432109876543210987654321", - networkSymbol: "ETH", + network: .mockMainnet, details: .ethSend( .init( fromToken: network.nativeToken, @@ -219,7 +219,7 @@ class TransactionParserTests: XCTestCase { fromAddress: accountInfos[0].address, namedToAddress: "Ethereum Account 2", toAddress: "0x0987654321098765432109876543210987654321", - networkSymbol: "ETH", + network: .mockMainnet, details: .erc20Transfer( .init( fromToken: .previewDaiToken, @@ -305,7 +305,7 @@ class TransactionParserTests: XCTestCase { fromAddress: accountInfos[0].address, namedToAddress: "0x Exchange Proxy", toAddress: "0xDef1C0ded9bec7F1a1670819833240f027b25EfF", - networkSymbol: "ETH", + network: .mockMainnet, details: .ethSwap( .init( fromToken: .previewToken, @@ -395,7 +395,7 @@ class TransactionParserTests: XCTestCase { fromAddress: accountInfos[0].address, namedToAddress: "0x Exchange Proxy", toAddress: "0xDef1C0ded9bec7F1a1670819833240f027b25EfF", - networkSymbol: "ETH", + network: .mockMainnet, details: .ethSwap( .init( fromToken: .mockUSDCToken, @@ -481,13 +481,14 @@ class TransactionParserTests: XCTestCase { fromAddress: accountInfos[0].address, namedToAddress: BraveWallet.BlockchainToken.previewDaiToken.contractAddress.truncatedAddress, toAddress: BraveWallet.BlockchainToken.previewDaiToken.contractAddress, - networkSymbol: "ETH", + network: .mockMainnet, details: .ethErc20Approve( .init( token: .previewDaiToken, tokenContractAddress: BraveWallet.BlockchainToken.previewDaiToken.contractAddress, approvalValue: "0x2386f26fc10000", approvalAmount: "0.01", + approvalFiat: "$0.02", isUnlimited: false, spenderAddress: "", gasFee: .init( @@ -565,13 +566,14 @@ class TransactionParserTests: XCTestCase { fromAddress: accountInfos[0].address, namedToAddress: BraveWallet.BlockchainToken.previewDaiToken.contractAddress.truncatedAddress, toAddress: BraveWallet.BlockchainToken.previewDaiToken.contractAddress, - networkSymbol: "ETH", + network: .mockMainnet, details: .ethErc20Approve( .init( token: .previewDaiToken, tokenContractAddress: BraveWallet.BlockchainToken.previewDaiToken.contractAddress, approvalValue: "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", approvalAmount: "Unlimited", + approvalFiat: "Unlimited", isUnlimited: true, spenderAddress: "", gasFee: .init( @@ -653,7 +655,7 @@ class TransactionParserTests: XCTestCase { fromAddress: accountInfos[0].address, namedToAddress: "Ethereum Account 2", toAddress: "0x0987654321098765432109876543210987654321", - networkSymbol: "ETH", + network: .mockMainnet, details: .erc721Transfer( .init( fromToken: .previewDaiToken, @@ -737,7 +739,7 @@ class TransactionParserTests: XCTestCase { fromAddress: accountInfos[2].accountId.address, namedToAddress: accountInfos[3].name, toAddress: accountInfos[3].accountId.address, - networkSymbol: "SOL", + network: .mockSolana, details: .solSystemTransfer( .init( fromToken: .mockSolToken, @@ -834,7 +836,7 @@ class TransactionParserTests: XCTestCase { fromAddress: accountInfos[2].accountId.address, namedToAddress: accountInfos[3].name, toAddress: accountInfos[3].accountId.address, - networkSymbol: "SOL", + network: .mockSolana, details: .solSplTokenTransfer( .init( fromToken: .mockSpdToken, @@ -918,7 +920,7 @@ class TransactionParserTests: XCTestCase { fromAddress: accountInfos[2].accountId.address, namedToAddress: accountInfos[3].name, toAddress: accountInfos[3].accountId.address, - networkSymbol: "SOL", + network: .mockSolana, details: .solSplTokenTransfer( .init( fromToken: .mockSolanaNFTToken, @@ -1129,7 +1131,7 @@ class TransactionParserTests: XCTestCase { fromAddress: accountInfos[4].address, namedToAddress: accountInfos[5].name, toAddress: accountInfos[5].address, - networkSymbol: "FIL", + network: .mockFilecoinTestnet, details: .filSend( .init( sendToken: .mockFilToken, diff --git a/Tests/BraveWalletTests/TransactionsActivityStoreTests.swift b/Tests/BraveWalletTests/TransactionsActivityStoreTests.swift index 7c249dacbce3..775eacc547ca 100644 --- a/Tests/BraveWalletTests/TransactionsActivityStoreTests.swift +++ b/Tests/BraveWalletTests/TransactionsActivityStoreTests.swift @@ -90,10 +90,16 @@ class TransactionsActivityStoreTests: XCTestCase { let filSendTxCopy = BraveWallet.TransactionInfo.mockFilUnapprovedSend.copy() as! BraveWallet.TransactionInfo let filTestnetSendTxCopy = BraveWallet.TransactionInfo.mockFilUnapprovedSend.copy() as! BraveWallet.TransactionInfo filTestnetSendTxCopy.chainId = BraveWallet.FilecoinTestnet - let mockTxs: [BraveWallet.TransactionInfo] = [ethSendTxCopy, goerliSwapTxCopy, solSendTxCopy, solTestnetSendTxCopy, filSendTxCopy, filTestnetSendTxCopy].enumerated().map { (index, tx) in + let txs: [BraveWallet.TransactionInfo] = [ethSendTxCopy, goerliSwapTxCopy, solSendTxCopy, solTestnetSendTxCopy, filSendTxCopy, filTestnetSendTxCopy] + var timeIntervalIncrement: TimeInterval = 0 + let mockTxs: [BraveWallet.TransactionInfo] = txs.enumerated().map { (index, tx) in tx.txStatus = .unapproved // transactions sorted by created time, make sure they are in-order - tx.createdTime = firstTransactionDate.addingTimeInterval(TimeInterval(index)) + timeIntervalIncrement += TimeInterval(index) + if index % 2 == 0 { // 2 transactions per day + timeIntervalIncrement += 1.days + } + tx.createdTime = firstTransactionDate.addingTimeInterval(timeIntervalIncrement) return tx } @@ -101,11 +107,11 @@ class TransactionsActivityStoreTests: XCTestCase { txService._addObserver = { _ in } txService._allTransactionInfo = { coin, chainId, address, completion in if coin == .eth { - completion([ethSendTxCopy, goerliSwapTxCopy].filter({ $0.chainId == chainId })) + completion(mockTxs.filter({ $0.chainId == chainId && $0.coin == coin })) } else if coin == .sol { - completion([solSendTxCopy, solTestnetSendTxCopy].filter({ $0.chainId == chainId })) + completion(mockTxs.filter({ $0.chainId == chainId && $0.coin == coin })) } else { // .fil - completion([filSendTxCopy, filTestnetSendTxCopy].filter({ $0.chainId == chainId })) + completion(mockTxs.filter({ $0.chainId == chainId && $0.coin == coin })) } } @@ -133,57 +139,123 @@ class TransactionsActivityStoreTests: XCTestCase { ) let transactionsExpectation = expectation(description: "transactionsExpectation") - store.$transactionSummaries + store.$transactionSections .dropFirst() - .collect(2) // without asset prices, with asset prices - .sink { transactionSummariesUpdates in + .collect(2) + .sink { transactionSectionsUpdates in defer { transactionsExpectation.fulfill() } - guard let transactionSummariesWithoutPrices = transactionSummariesUpdates.first, - let transactionSummariesWithPrices = transactionSummariesUpdates[safe: 1] else { + guard let transactionSectionsWithoutPrices = transactionSectionsUpdates.first, + let transactionSectionsWithPrices = transactionSectionsUpdates[safe: 1] else { XCTFail("Expected 2 updates to transactionSummaries") return } let expectedTransactions = mockTxs // verify all transactions from supported coin types are shown - XCTAssertEqual(transactionSummariesWithoutPrices.count, expectedTransactions.count) - XCTAssertEqual(transactionSummariesWithPrices.count, expectedTransactions.count) - // verify sorted by `createdTime` + XCTAssertEqual( + transactionSectionsWithoutPrices.flatMap(\.transactions).count, + expectedTransactions.count) + XCTAssertEqual( + transactionSectionsWithPrices.flatMap(\.transactions).count, + expectedTransactions.count) + // verify sections sorted by `createdTime` + XCTAssertEqual( + transactionSectionsWithoutPrices.map(\.date), + transactionSectionsWithoutPrices.map(\.date).sorted(by: { $0 > $1})) + XCTAssertEqual( + transactionSectionsWithPrices.map(\.date), + transactionSectionsWithPrices.map(\.date).sorted(by: { $0 > $1})) + // verify transactions sorted by `createdTime` let expectedSortedOrder = expectedTransactions.sorted(by: { $0.createdTime > $1.createdTime }) - - XCTAssertEqual(transactionSummariesWithoutPrices.map(\.txInfo.txHash), expectedSortedOrder.map(\.txHash)) - XCTAssertEqual(transactionSummariesWithPrices.map(\.txInfo.txHash), expectedSortedOrder.map(\.txHash)) - // verify they are populated with correct tx (summaries are tested in `TransactionParserTests`) - XCTAssertEqual(transactionSummariesWithoutPrices[safe: 0]?.txInfo, filTestnetSendTxCopy) - XCTAssertEqual(transactionSummariesWithoutPrices[safe: 0]?.txInfo.chainId, filTestnetSendTxCopy.chainId) - XCTAssertEqual(transactionSummariesWithPrices[safe: 0]?.txInfo, filTestnetSendTxCopy) - - XCTAssertEqual(transactionSummariesWithoutPrices[safe: 1]?.txInfo, filSendTxCopy) - XCTAssertEqual(transactionSummariesWithoutPrices[safe: 1]?.txInfo.chainId, filSendTxCopy.chainId) - XCTAssertEqual(transactionSummariesWithPrices[safe: 1]?.txInfo, filSendTxCopy) - - XCTAssertEqual(transactionSummariesWithoutPrices[safe: 2]?.txInfo, solTestnetSendTxCopy) - XCTAssertEqual(transactionSummariesWithoutPrices[safe: 2]?.txInfo.chainId, solTestnetSendTxCopy.chainId) - XCTAssertEqual(transactionSummariesWithPrices[safe: 2]?.txInfo, solTestnetSendTxCopy) - - XCTAssertEqual(transactionSummariesWithoutPrices[safe: 3]?.txInfo, solSendTxCopy) - XCTAssertEqual(transactionSummariesWithoutPrices[safe: 3]?.txInfo.chainId, solSendTxCopy.chainId) - XCTAssertEqual(transactionSummariesWithPrices[safe: 3]?.txInfo, solSendTxCopy) + XCTAssertEqual( + transactionSectionsWithoutPrices.flatMap(\.transactions).map(\.transaction.txHash), + expectedSortedOrder.map(\.txHash)) + XCTAssertEqual( + transactionSectionsWithPrices.flatMap(\.transactions).map(\.transaction.txHash), + expectedSortedOrder.map(\.txHash)) - XCTAssertEqual(transactionSummariesWithoutPrices[safe: 4]?.txInfo, goerliSwapTxCopy) - XCTAssertEqual(transactionSummariesWithoutPrices[safe: 4]?.txInfo.chainId, goerliSwapTxCopy.chainId) - XCTAssertEqual(transactionSummariesWithPrices[safe: 4]?.txInfo, goerliSwapTxCopy) + // verify transactions are populated with correct ParsedTransaction + // Day 1 Transaction 1 + XCTAssertEqual( + transactionSectionsWithoutPrices[safe: 0]?.transactions[safe: 0]?.transaction, + filTestnetSendTxCopy) + XCTAssertEqual( + transactionSectionsWithoutPrices[safe: 0]?.transactions[safe: 0]?.transaction.chainId, + filTestnetSendTxCopy.chainId) + XCTAssertEqual( + transactionSectionsWithPrices[safe: 0]?.transactions[safe: 0]?.transaction, + filTestnetSendTxCopy) + // Day 1 Transaction 2 + XCTAssertEqual( + transactionSectionsWithoutPrices[safe: 0]?.transactions[safe: 1]?.transaction, + filSendTxCopy) + XCTAssertEqual( + transactionSectionsWithoutPrices[safe: 0]?.transactions[safe: 1]?.transaction.chainId, + filSendTxCopy.chainId) + XCTAssertEqual( + transactionSectionsWithPrices[safe: 0]?.transactions[safe: 1]?.transaction, + filSendTxCopy) + // Day 2 Transaction 1 + XCTAssertEqual( + transactionSectionsWithoutPrices[safe: 1]?.transactions[safe: 0]?.transaction, + solTestnetSendTxCopy) + XCTAssertEqual( + transactionSectionsWithoutPrices[safe: 1]?.transactions[safe: 0]?.transaction.chainId, + solTestnetSendTxCopy.chainId) + XCTAssertEqual( + transactionSectionsWithPrices[safe: 1]?.transactions[safe: 0]?.transaction, + solTestnetSendTxCopy) + // Day 2 Transaction 2 + XCTAssertEqual( + transactionSectionsWithoutPrices[safe: 1]?.transactions[safe: 1]?.transaction, + solSendTxCopy) + XCTAssertEqual( + transactionSectionsWithoutPrices[safe: 1]?.transactions[safe: 1]?.transaction.chainId, + solSendTxCopy.chainId) + XCTAssertEqual( + transactionSectionsWithPrices[safe: 1]?.transactions[safe: 1]?.transaction, + solSendTxCopy) + // Day 3 Transaction 1 + XCTAssertEqual( + transactionSectionsWithoutPrices[safe: 2]?.transactions[safe: 0]?.transaction, + goerliSwapTxCopy) + XCTAssertEqual( + transactionSectionsWithoutPrices[safe: 2]?.transactions[safe: 0]?.transaction.chainId, + goerliSwapTxCopy.chainId) + XCTAssertEqual( + transactionSectionsWithPrices[safe: 2]?.transactions[safe: 0]?.transaction, + goerliSwapTxCopy) + // Day 3 Transaction 2 + XCTAssertEqual( + transactionSectionsWithoutPrices[safe: 2]?.transactions[safe: 1]?.transaction, + ethSendTxCopy) + XCTAssertEqual( + transactionSectionsWithoutPrices[safe: 2]?.transactions[safe: 1]?.transaction.chainId, + ethSendTxCopy.chainId) + XCTAssertEqual( + transactionSectionsWithPrices[safe: 2]?.transactions[safe: 1]?.transaction, + ethSendTxCopy) - XCTAssertEqual(transactionSummariesWithoutPrices[safe: 5]?.txInfo, ethSendTxCopy) - XCTAssertEqual(transactionSummariesWithoutPrices[safe: 5]?.txInfo.chainId, ethSendTxCopy.chainId) - XCTAssertEqual(transactionSummariesWithPrices[safe: 5]?.txInfo, ethSendTxCopy) + XCTAssertNil(transactionSectionsWithPrices[safe: 3]) // verify gas fee fiat - XCTAssertEqual(transactionSummariesWithPrices[safe: 0]?.gasFee?.fiat, "$0.0000006232") - XCTAssertEqual(transactionSummariesWithPrices[safe: 1]?.gasFee?.fiat, "$0.0000006232") - XCTAssertEqual(transactionSummariesWithPrices[safe: 2]?.gasFee?.fiat, "$0.000000002") - XCTAssertEqual(transactionSummariesWithPrices[safe: 3]?.gasFee?.fiat, "$0.000000002") - XCTAssertEqual(transactionSummariesWithPrices[safe: 4]?.gasFee?.fiat, "$255.03792654") - XCTAssertEqual(transactionSummariesWithPrices[safe: 5]?.gasFee?.fiat, "$10.41008598" ) + XCTAssertEqual( + transactionSectionsWithPrices[safe: 0]?.transactions[safe: 0]?.gasFee?.fiat, + "$0.0000006232") + XCTAssertEqual( + transactionSectionsWithPrices[safe: 0]?.transactions[safe: 1]?.gasFee?.fiat, + "$0.0000006232") + XCTAssertEqual( + transactionSectionsWithPrices[safe: 1]?.transactions[safe: 0]?.gasFee?.fiat, + "$0.000000002") + XCTAssertEqual( + transactionSectionsWithPrices[safe: 1]?.transactions[safe: 1]?.gasFee?.fiat, + "$0.000000002") + XCTAssertEqual( + transactionSectionsWithPrices[safe: 2]?.transactions[safe: 0]?.gasFee?.fiat, + "$255.03792654") + XCTAssertEqual( + transactionSectionsWithPrices[safe: 2]?.transactions[safe: 1]?.gasFee?.fiat, + "$10.41008598" ) } .store(in: &cancellables)