Skip to content
This repository was archived by the owner on Mar 26, 2026. It is now read-only.

Commit cc685ec

Browse files
authored
Collections updates (#1822)
* Improve NFT image placeholder (#1814) - Replace hardcoded Colors.grayLight with adaptive systemGray5 background - Handle nil image URL to avoid infinite loading spinner for NFTs without images (e.g. ENS domains) - Add NftImagePlaceholderView with proportional circle + photo icon that clearly communicates "no image" - Show NFT name on placeholder in detail view only - Extract placeholder into separate reusable component * Add VerifiedBadgeView with white checkmark on blue seal - Extract reusable VerifiedBadgeView component - Use consistent white checkmark styling across grid and detail views * Disable context menu to save to photos if ONLY placaholder image Added isImageLoaded to track if image failed to download * Clean up NFT module: remove unused imports, dead code, fix access modifiers - Remove unused imports: Photos, ImageGalleryService, Foundation, Style, Localization - Remove dead `description` property that shadows CustomStringConvertible - Move private members to private extension: contractValue, contractExplorerUrl, enabledChainTypes - Make enabledChainTypes static since it's constant across instances * Update layout consitence for Collections & Collectible * Extract magic numbers into Layout constants in NftImagePlaceholderView * Update NftImagePlaceholderView.swift
1 parent 2477f40 commit cc685ec

9 files changed

Lines changed: 131 additions & 65 deletions

File tree

Features/NFT/Sources/Scenes/CollectibleScene.swift

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
// Copyright (c). Gem Wallet. All rights reserved.
22

3-
import Foundation
43
import Primitives
54
import SwiftUI
65
import Style
76
import Components
87
import PrimitivesComponents
98
import Localization
10-
import ImageGalleryService
119
import InfoSheet
1210

1311
public struct CollectibleScene: View {
@@ -33,16 +31,15 @@ public struct CollectibleScene: View {
3331
}
3432
.environment(\.defaultMinListHeaderHeight, 0)
3533
.listSectionSpacing(.compact)
34+
.contentMargins([.top], .small, for: .scrollContent)
3635
.navigationTitle(model.title)
3736
.toolbar {
3837
ToolbarItem(placement: .principal) {
39-
HStack(spacing: Spacing.tiny) {
38+
HStack(spacing: .tiny) {
4039
Text(model.title)
4140
.font(.headline)
4241
if model.isVerified {
43-
Images.System.checkmarkSealFill
44-
.font(.footnote)
45-
.foregroundStyle(Colors.blue)
42+
VerifiedBadgeView()
4643
}
4744
}
4845
}
@@ -70,8 +67,11 @@ public struct CollectibleScene: View {
7067
extension CollectibleScene {
7168
private var headerSectionView: some View {
7269
Section {
73-
NftImageView(assetImage: model.assetImage)
74-
.aspectRatio(1, contentMode: .fill)
70+
NftImageView(
71+
assetImage: model.assetImage,
72+
isImageLoaded: $model.isImageLoaded
73+
)
74+
.aspectRatio(1, contentMode: .fill)
7575
} header: {
7676
Spacer()
7777
} footer: {
@@ -83,18 +83,7 @@ extension CollectibleScene {
8383
.textCase(nil)
8484
.listRowSeparator(.hidden)
8585
.listRowInsets(EdgeInsets())
86-
.contextMenu([
87-
.custom(
88-
title: Localized.Nft.saveToPhotos,
89-
systemImage: SystemImage.gallery,
90-
action: model.onSelectSaveToGallery
91-
),
92-
.custom(
93-
title: Localized.Nft.setAsAvatar,
94-
systemImage: SystemImage.emoji,
95-
action: model.onSelectSetAsAvatar
96-
)
97-
])
86+
.contextMenu(model.imageContextMenuItems)
9887
}
9988

10089
private var statusSectionView: some View {

Features/NFT/Sources/Scenes/CollectionsScene.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ public struct CollectionsScene<ViewModel: CollectionsViewable>: View {
2323
LazyVGrid(columns: model.columns) {
2424
collectionsView
2525
}
26-
.padding(.horizontal, .medium)
26+
.padding(.horizontal, Spacing.medium + Spacing.tiny)
2727

2828
Spacer(minLength: .medium)
2929

@@ -44,6 +44,7 @@ public struct CollectionsScene<ViewModel: CollectionsViewable>: View {
4444
}
4545
}
4646
.bindQuery(model.query)
47+
.contentMargins(.top, .scene.top, for: .scrollContent)
4748
.overlay {
4849
if model.content.items.isEmpty {
4950
EmptyContentView(model: model.emptyContentModel)

Features/NFT/Sources/Scenes/ReportSelectReasonScene.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import SwiftUI
44
import Components
55
import Primitives
6-
import Localization
76

87
struct ReportSelectReasonScene: View {
98
private let model: ReportNftViewModel

Features/NFT/Sources/ViewModels/CollectibleViewModel.swift

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import Localization
88
import Components
99
import Style
1010
import ImageGalleryService
11-
import Photos
1211
import AvatarService
1312
import Formatters
1413
import ExplorerService
@@ -31,6 +30,7 @@ public final class CollectibleViewModel {
3130
var isPresentingSelectedAssetInput: Binding<SelectedAssetInput?>
3231
var isPresentingReportSheet = false
3332
var isPresentingInfoSheet: InfoSheetType?
33+
var isImageLoaded = false
3434

3535
public init(
3636
wallet: Wallet,
@@ -49,7 +49,22 @@ public final class CollectibleViewModel {
4949
}
5050

5151
var title: String { assetData.asset.name }
52-
var description: String? { assetData.asset.description }
52+
53+
var imageContextMenuItems: [ContextMenuItemType] {
54+
guard isImageLoaded else { return [] }
55+
return [
56+
.custom(
57+
title: Localized.Nft.saveToPhotos,
58+
systemImage: SystemImage.gallery,
59+
action: onSelectSaveToGallery
60+
),
61+
.custom(
62+
title: Localized.Nft.setAsAvatar,
63+
systemImage: SystemImage.emoji,
64+
action: onSelectSetAsAvatar
65+
),
66+
]
67+
}
5368

5469
var collectionField: ListItemField {
5570
ListItemField(title: Localized.Nft.collection, value: assetData.collection.name)
@@ -63,7 +78,6 @@ public final class CollectibleViewModel {
6378
ListItemField(title: Localized.Transfer.network, value: assetData.asset.chain.asset.name)
6479
}
6580

66-
var contractValue: String { assetData.collection.contractAddress }
6781
var contractField: ListItemField? {
6882
if contractValue.isEmpty || contractValue == assetData.asset.tokenId {
6983
return .none
@@ -72,10 +86,6 @@ public final class CollectibleViewModel {
7286
return ListItemField(title: Localized.Asset.contract, value: text)
7387
}
7488

75-
var contractExplorerUrl: BlockExplorerLink? {
76-
explorerService.tokenUrl(chain: assetData.asset.chain, address: contractValue)
77-
}
78-
7989
var contractContextMenu: [ContextMenuItemType] {
8090
[
8191
.copy(value: contractValue, onCopy: { [weak self] value in
@@ -112,12 +122,10 @@ public final class CollectibleViewModel {
112122
)
113123
}
114124

115-
let enabledChainTypes: Set<ChainType> = [ChainType.ethereum]
116-
117125
var isSendEnabled: Bool {
118126
wallet.canSign &&
119127
assetData.asset.chain.isNFTSupported &&
120-
enabledChainTypes .contains(assetData.asset.chain.type)
128+
Self.enabledChainTypes.contains(assetData.asset.chain.type)
121129
}
122130

123131
var headerButtons: [HeaderButton] {
@@ -264,11 +272,17 @@ extension CollectibleViewModel {
264272
// MARK: - Private
265273

266274
extension CollectibleViewModel {
275+
private static let enabledChainTypes: Set<ChainType> = [.ethereum]
276+
private var contractValue: String { assetData.collection.contractAddress }
277+
private var contractExplorerUrl: BlockExplorerLink? {
278+
explorerService.tokenUrl(chain: assetData.asset.chain, address: contractValue)
279+
}
280+
267281
private func openSettings() {
268282
guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { return }
269283
UIApplication.shared.open(settingsURL)
270284
}
271-
285+
272286
private func setWalletAvatar() async throws {
273287
guard let url = assetData.asset.images.preview.url.asURL else { return }
274288
try await avatarService.save(url: url, for: wallet)

Features/NFT/Sources/ViewModels/CollectionsViewModel.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import Localization
99
import SwiftUI
1010
import PrimitivesComponents
1111
import WalletService
12-
import Style
1312

1413
@Observable
1514
@MainActor

Packages/Components/Sources/Grid/GridPosterView.swift

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,7 @@ public struct GridPosterView: View {
3131
.multilineTextAlignment(.leading)
3232

3333
if model.isVerified {
34-
Images.System.checkmarkSealFill
35-
.font(.callout)
36-
.foregroundStyle(Colors.blue)
34+
VerifiedBadgeView()
3735
}
3836
}
3937
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c). Gem Wallet. All rights reserved.
2+
3+
import SwiftUI
4+
import Style
5+
6+
public struct NftImagePlaceholderView: View {
7+
private struct Layout {
8+
static let nameVisibilityThreshold: CGFloat = 250
9+
static let circleSizeRatioWithText: CGFloat = 0.3
10+
static let circleSizeRatioDefault: CGFloat = 0.35
11+
static let iconSizeRatio: CGFloat = 0.45
12+
}
13+
14+
private let name: String?
15+
16+
public init(name: String? = nil) {
17+
self.name = name
18+
}
19+
20+
public var body: some View {
21+
GeometryReader { geometry in
22+
let size = min(geometry.size.width, geometry.size.height)
23+
let showName = size > Layout.nameVisibilityThreshold
24+
let circleSize = size * (showName ? Layout.circleSizeRatioWithText : Layout.circleSizeRatioDefault)
25+
let iconSize = circleSize * Layout.iconSizeRatio
26+
ZStack {
27+
Color(.systemGray5)
28+
VStack(spacing: Spacing.medium) {
29+
ZStack {
30+
Circle()
31+
.foregroundStyle(Color(.systemGray4))
32+
Images.System.photo
33+
.resizable()
34+
.aspectRatio(contentMode: .fit)
35+
.frame(width: iconSize, height: iconSize)
36+
.foregroundStyle(Colors.Empty.image)
37+
}
38+
.frame(size: circleSize)
39+
40+
if showName, let name, !name.isEmpty {
41+
Text(name)
42+
.font(.callout.weight(.medium))
43+
.foregroundStyle(Color(.secondaryLabel))
44+
.lineLimit(2)
45+
.multilineTextAlignment(.center)
46+
.padding(.horizontal, Spacing.extraLarge)
47+
}
48+
}
49+
}
50+
.frame(width: geometry.size.width, height: geometry.size.height)
51+
}
52+
}
53+
}

Packages/Components/Sources/NftImageView.swift

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,47 +5,45 @@ import SwiftUI
55
import Style
66

77
public struct NftImageView: View {
8-
9-
let assetImage: AssetImage
10-
11-
public init(assetImage: AssetImage) {
8+
9+
private let assetImage: AssetImage
10+
@Binding private var isImageLoaded: Bool
11+
12+
public init(
13+
assetImage: AssetImage,
14+
isImageLoaded: Binding<Bool> = .constant(false)
15+
) {
1216
self.assetImage = assetImage
17+
_isImageLoaded = isImageLoaded
1318
}
14-
19+
1520
public var body: some View {
1621
CachedAsyncImage(url: assetImage.imageURL) { phase in
1722
switch phase {
1823
case .empty:
19-
ZStack {
20-
Rectangle()
21-
.foregroundStyle(Colors.grayLight)
22-
if assetImage.placeholder != nil {
23-
AssetImageView(assetImage: assetImage, size: .image.large)
24-
} else {
25-
LoadingView()
24+
if assetImage.imageURL != nil {
25+
ZStack {
26+
Color(.systemGray5)
27+
if assetImage.placeholder != nil {
28+
AssetImageView(
29+
assetImage: assetImage,
30+
size: .image.large
31+
)
32+
} else {
33+
LoadingView()
34+
}
2635
}
36+
} else {
37+
NftImagePlaceholderView(name: assetImage.type)
2738
}
2839
case .success(let image):
2940
image.resizable()
41+
.onAppear { isImageLoaded = true }
3042
case .failure:
31-
errorView
43+
NftImagePlaceholderView(name: assetImage.type)
3244
@unknown default:
33-
errorView
34-
}
35-
}
36-
}
37-
38-
private var errorView: some View {
39-
ZStack {
40-
Rectangle()
41-
.foregroundStyle(Colors.grayLight)
42-
if let type = assetImage.type {
43-
Text(type)
44-
.font(.body)
45-
.foregroundStyle(Colors.black.opacity(.strong))
46-
.padding(.small)
45+
NftImagePlaceholderView(name: assetImage.type)
4746
}
4847
}
49-
.frame(maxWidth: .infinity)
5048
}
5149
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright (c). Gem Wallet. All rights reserved.
2+
3+
import SwiftUI
4+
import Style
5+
6+
public struct VerifiedBadgeView: View {
7+
8+
public init() {}
9+
10+
public var body: some View {
11+
Images.System.checkmarkSealFill
12+
.font(.callout)
13+
.foregroundStyle(Colors.whiteSolid, Colors.blue)
14+
}
15+
}

0 commit comments

Comments
 (0)