Skip to content
This repository has been archived by the owner on May 10, 2024. It is now read-only.

Fix 8064: Some v2 updates in NFT details #8408

Merged
merged 8 commits into from
Nov 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
327 changes: 228 additions & 99 deletions Sources/BraveWallet/Crypto/NFT/NFTDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,37 @@ import BraveUI
import SDWebImageSwiftUI

struct NFTDetailView: View {
@ObservedObject var keyringStore: KeyringStore
@ObservedObject var nftDetailStore: NFTDetailStore
@Binding var buySendSwapDestination: BuySendSwapDestination?
var onNFTMetadataRefreshed: ((NFTMetadata) -> Void)?
var onNFTStatusUpdated: (() -> Void)?

@Environment(\.openURL) private var openWalletURL
@Environment(\.presentationMode) @Binding private var presentationMode
@Environment(\.horizontalSizeClass) private var horizontalSizeClass

@State private var isPresentingRemoveAlert: Bool = false

@ViewBuilder private var noImageView: some View {
Text(Strings.Wallet.nftDetailImageNotAvailable)
.foregroundColor(Color(.secondaryBraveLabel))
.frame(maxWidth: .infinity, minHeight: 300)
}

@ViewBuilder private var nftLogo: some View {
if let image = nftDetailStore.networkInfo.nativeTokenLogoImage, !nftDetailStore.isLoading {
Image(uiImage: image)
.resizable()
.frame(width: 32, height: 32)
.overlay {
Circle()
.stroke(lineWidth: 2)
.foregroundColor(Color(braveSystemName: .containerBackground))
}
}
}

@ViewBuilder private var nftImage: some View {
NFTImageView(urlString: nftDetailStore.nftMetadata?.imageURLString ?? "", isLoading: nftDetailStore.isLoading) {
noImageView
Expand All @@ -36,9 +55,9 @@ struct NFTDetailView: View {
}

var body: some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 24) {
VStack(spacing: 8) {
Form {
Section {
VStack(alignment: .leading, spacing: 16) {
nftImage
.overlay(alignment: .topLeading) {
if nftDetailStore.nft.isSpam {
Expand All @@ -60,129 +79,239 @@ struct NFTDetailView: View {
.padding(12)
}
}
.overlay(alignment: .bottomTrailing) {
ZStack {
if let owner = nftDetailStore.owner {
Blockie(address: owner.address, shape: .rectangle)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(lineWidth: 2)
.foregroundColor(Color(braveSystemName: .containerBackground))
)
.frame(width: 32, height: 32)
.zIndex(1)
.offset(x: -28)
}
nftLogo
}
.offset(y: 16)
}
VStack(alignment: .leading, spacing: 8) {
Text(nftDetailStore.nft.nftTokenTitle)
Text(nftDetailStore.nft.nftDetailTitle)
.font(.title3.weight(.semibold))
.foregroundColor(Color(.braveLabel))
Text(nftDetailStore.nft.name)
.foregroundColor(Color(.secondaryBraveLabel))
Button(action: {
buySendSwapDestination = BuySendSwapDestination(
kind: .send,
initialToken: nftDetailStore.nft
)
}) {
Text(Strings.Wallet.nftDetailSendNFTButtonTitle)
.frame(maxWidth: .infinity)
}
.buttonStyle(BraveFilledButtonStyle(size: .large))
}
}
if let nftMetadata = nftDetailStore.nftMetadata, let description = nftMetadata.description, !description.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text(Strings.Wallet.nftDetailDescription)
.font(.headline.weight(.semibold))
.foregroundColor(Color(.braveLabel))
Text(description)
.foregroundColor(Color(.braveLabel))
.transaction { transaction in
transaction.animation = nil
transaction.disablesAnimations = true
}
}
VStack(spacing: 16) {
Group {
HStack {
Text(Strings.Wallet.nftDetailBlockchain)
.font(.headline.weight(.semibold))
Spacer()
Text(nftDetailStore.networkInfo.chainName)
.listRowInsets(.zero)
.listRowBackground(Color.clear)
}
Section {
List {
if let owner = nftDetailStore.owner {
NFTDetailRow(title: Strings.Wallet.nftDetailOwnedBy) {
AddressView(address: owner.address) {
HStack {
Text(owner.name)
.foregroundColor(Color(.braveBlurpleTint))
Text(owner.address.truncatedAddress)
.foregroundColor(Color(.braveLabel))
}
.font(.subheadline)
}
}
HStack {
Text(Strings.Wallet.nftDetailTokenStandard)
.font(.headline.weight(.semibold))
Spacer()
Text(nftDetailStore.nft.isErc721 ? Strings.Wallet.nftDetailERC721 : Strings.Wallet.nftDetailSPL)
}
if nftDetailStore.nft.isErc721, let tokenId = Int(nftDetailStore.nft.tokenId.removingHexPrefix, radix: 16) {
NFTDetailRow(title: Strings.Wallet.nftDetailTokenID) {
Text("\(tokenId)")
.font(.subheadline)
.foregroundColor(Color(.braveLabel))
}
if nftDetailStore.nft.isErc721 {
HStack {
Text(Strings.Wallet.nftDetailTokenID)
.font(.headline.weight(.semibold))
Spacer()
Button(action: {
if let explorerURL = nftDetailStore.networkInfo.blockExplorerUrls.first {
let baseURL = "\(explorerURL)/token/\(nftDetailStore.nft.contractAddress)"
var nftURL = URL(string: baseURL)
if let tokenId = Int(nftDetailStore.nft.tokenId.removingHexPrefix, radix: 16) {
nftURL = URL(string: "\(baseURL)?a=\(tokenId)")
}

if let url = nftURL {
openWalletURL(url)
}
}
}) {
if let tokenId = Int(nftDetailStore.nft.tokenId.removingHexPrefix, radix: 16) {
HStack {
Text(verbatim: "#\(tokenId)")
Image(systemName: "arrow.up.forward.square")
}
.foregroundColor(Color(.braveBlurple))
} else {
HStack {
Text("\(nftDetailStore.nft.name) #\(nftDetailStore.nft.tokenId)")
Image(systemName: "arrow.up.forward.square")
}
.foregroundColor(Color(.braveBlurple))
}
}
}
NFTDetailRow(title: nftDetailStore.nft.isErc721 ? Strings.Wallet.contractAddressAccessibilityLabel : Strings.Wallet.tokenMintAddress) {
Button {
if let url = nftDetailStore.networkInfo.nftBlockExplorerURL(nftDetailStore.nft) {
openWalletURL(url)
}
} else {
} label: {
HStack {
Text(Strings.Wallet.tokenMintAddress)
.font(.headline.weight(.semibold))
Spacer()
Button(action: {
if WalletConstants.supportedTestNetworkChainIds.contains(nftDetailStore.networkInfo.chainId) {
if let components = nftDetailStore.networkInfo.blockExplorerUrls.first?.separatedBy("/?cluster="), let baseURL = components.first {
let cluster = components.last ?? ""
if let nftURL = URL(string: "\(baseURL)/address/\(nftDetailStore.nft.contractAddress)/?cluster=\(cluster)") {
openWalletURL(nftURL)
}
}
} else {
if let explorerURL = nftDetailStore.networkInfo.blockExplorerUrls.first, let nftURL = URL(string: "\(explorerURL)/address/\(nftDetailStore.nft.contractAddress)") {
openWalletURL(nftURL)
}
}
}) {
HStack {
Text("\(nftDetailStore.nft.contractAddress.truncatedAddress)")
Image(systemName: "arrow.up.forward.square")
}
.foregroundColor(Color(.braveBlurple))
}
Text(nftDetailStore.nft.contractAddress.truncatedAddress)
Image(systemName: "arrow.up.forward.square")
}
.font(.subheadline)
.foregroundColor(Color(.braveBlurpleTint))
}
}
.foregroundColor(Color(.braveLabel))
NFTDetailRow(title: Strings.Wallet.nftDetailBlockchain) {
Text(nftDetailStore.networkInfo.chainName)
.font(.subheadline)
.foregroundColor(Color(.braveLabel))
}
NFTDetailRow(title: Strings.Wallet.nftDetailTokenStandard) {
Text(nftDetailStore.nft.isErc721 ? Strings.Wallet.nftDetailERC721 : Strings.Wallet.nftDetailSPL)
.font(.subheadline)
.foregroundColor(Color(.braveLabel))
}
}
if isSVGImage {
Text(Strings.Wallet.nftDetailSVGImageDisclaimer)
.multilineTextAlignment(.center)
.font(.footnote)
.foregroundColor(Color(.secondaryBraveLabel))
.frame(maxWidth: .infinity)
.listRowBackground(Color(.secondaryBraveGroupedBackground))
} header: {
Text(Strings.Wallet.nftDetailOverview)
}
if let nftMetadata = nftDetailStore.nftMetadata, let description = nftMetadata.description, !description.isEmpty {
Section {
Text(description)
.font(.subheadline)
.foregroundColor(Color(.braveLabel))
.listRowBackground(Color(.secondaryBraveGroupedBackground))
StephenHeaps marked this conversation as resolved.
Show resolved Hide resolved
.osAvailabilityModifiers({
if horizontalSizeClass == .regular {
$0.listRowInsets(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16))
} else {
$0
}
})
} header: {
Text(Strings.Wallet.nftDetailDescription)
}
}
if let attributes = nftDetailStore.nftMetadata?.attributes {
Section {
List {
ForEach(attributes) { attribute in
NFTDetailRow(title: attribute.type) {
Text(attribute.value)
.font(.subheadline)
.foregroundColor(Color(.braveLabel))
}
}
}
.listRowBackground(Color(.secondaryBraveGroupedBackground))
} header: {
Text(Strings.Wallet.nftDetailProperties)
}
}
.padding()
}
.listBackgroundColor(Color(.braveGroupedBackground))
.onChange(of: nftDetailStore.nftMetadata, perform: { newValue in
if let newMetadata = newValue {
onNFTMetadataRefreshed?(newMetadata)
}
})
.onChange(of: keyringStore.isWalletLocked, perform: { isLocked in
guard isLocked else { return }
if isPresentingRemoveAlert {
isPresentingRemoveAlert = false
}
})
.onAppear {
nftDetailStore.update()
}
.background(Color(UIColor.braveGroupedBackground).ignoresSafeArea())
.navigationBarTitle(Strings.Wallet.nftDetailTitle)
.navigationBarTitle(nftDetailStore.nft.nftDetailTitle)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
Menu {
if nftDetailStore.nft.visible {
Button(action: {
buySendSwapDestination = BuySendSwapDestination(
kind: .send,
initialToken: nftDetailStore.nft
)
}) {
Label(Strings.Wallet.nftDetailSendNFTButtonTitle, braveSystemImage: "leo.send")
}
.buttonStyle(BraveFilledButtonStyle(size: .large))
}
Button(action: {
if nftDetailStore.nft.visible { // a collected visible NFT, mark as hidden
nftDetailStore.updateNFTStatus(visible: false, isSpam: false, isDeletedByUser: false, completion: {
onNFTStatusUpdated?()
})
} else { // either a hidden NFT or a junk NFT, mark as visible
nftDetailStore.updateNFTStatus(visible: true, isSpam: false, isDeletedByUser: false, completion: {
onNFTStatusUpdated?()
})
}
}) {
if nftDetailStore.nft.visible { // a collected visible NFT
Label(Strings.recentSearchHide, braveSystemImage: "leo.eye.off")
} else if nftDetailStore.nft.isSpam { // a spam NFT
Label(Strings.Wallet.nftUnspam, braveSystemImage: "leo.disable.outline")
} else { // a hidden but not spam NFT
Label(Strings.Wallet.nftUnhide, braveSystemImage: "leo.eye.on")
}
}
Button(action: {
isPresentingRemoveAlert = true
}) {
Label(Strings.Wallet.nftRemoveFromWallet, braveSystemImage: "leo.trash")
}
} label: {
Label(Strings.Wallet.otherWalletActionsAccessibilityTitle, braveSystemImage: "leo.more.horizontal")
.labelStyle(.iconOnly)
.foregroundColor(Color(.braveBlurpleTint))
}
}
}
.background(
WalletPromptView(
StephenHeaps marked this conversation as resolved.
Show resolved Hide resolved
isPresented: $isPresentingRemoveAlert,
primaryButton: .init(
title: Strings.Wallet.manageSiteConnectionsConfirmAlertRemove,
action: { _ in
nftDetailStore.updateNFTStatus(visible: false, isSpam: nftDetailStore.nft.isSpam, isDeletedByUser: true, completion: {
onNFTStatusUpdated?()
presentationMode.dismiss()
})
isPresentingRemoveAlert = false
}
),
secondaryButton: .init(
title: Strings.CancelString,
action: { _ in
isPresentingRemoveAlert = false
}
),
showCloseButton: false,
content: {
VStack(spacing: 16) {
Text(Strings.Wallet.nftRemoveFromWalletAlertTitle)
.font(.headline)
.foregroundColor(Color(.bravePrimary))
Text(Strings.Wallet.nftRemoveFromWalletAlertDescription)
.font(.footnote)
.foregroundStyle(Color(.secondaryBraveLabel))
}
.padding(.bottom, 28)
})
)
}
}

struct NFTDetailRow<ValueContent: View>: View {
var title: String
var valueContent: () -> ValueContent

init(
title: String,
@ViewBuilder valueContent: @escaping () -> ValueContent
) {
self.title = title
self.valueContent = valueContent
}
var body: some View {
HStack {
Text(title)
.font(.subheadline)
.foregroundColor(Color(.secondaryLabel))
Spacer()
valueContent()
.multilineTextAlignment(.trailing)
}
}
}
Loading