From a9cd8bcde86d0beba2be73b64690615888cadbc2 Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 3 Sep 2026 01:30:24 -0400 Subject: [PATCH] Redesign listing cards around the image's real aspect ratio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feed cells were locked to a 4:3 box, so tall and square photos were cropped to whatever fit. `CachedImageView` now reports the decoded image's aspect ratio back to its caller, and `ProductGalleryCell` sizes the image from it — falling back to 4:3 only while loading. Cells are wider (46pt of chrome instead of 68) and the column gap tightened to match. The cell also gains the information the old one dropped: category and condition under the title, and a bookmark toggle that saves without opening the listing. `loadSavedState()` only asks the server about a single post while `HomeViewModel.savedItems` hasn't loaded yet. `savedItems` holds every saved post, so once it has arrived, absence is a real answer — the naive version fired one `/post/isSaved` request per visible cell on every scroll. That needed `hasLoadedSavedItems` on `HomeViewModel` to distinguish "not saved" from "not known yet". `CategoriesView` is extracted as its own component: the Shop By Category row is about to be used by Explore, and it is the same row Home already shows. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017R8mua9xepzF3mnRETtd6W --- Resell.xcodeproj/project.pbxproj | 4 + Resell/ViewModels/HomeViewModel.swift | 4 + Resell/Views/Components/CachedImageView.swift | 16 +- Resell/Views/Components/CategoriesView.swift | 43 ++++ .../Components/ProductsGalleryView.swift | 197 ++++++++++++++---- 5 files changed, 224 insertions(+), 40 deletions(-) create mode 100644 Resell/Views/Components/CategoriesView.swift diff --git a/Resell.xcodeproj/project.pbxproj b/Resell.xcodeproj/project.pbxproj index b114d51..80dd0f5 100644 --- a/Resell.xcodeproj/project.pbxproj +++ b/Resell.xcodeproj/project.pbxproj @@ -166,6 +166,7 @@ C6235EDE2FA1587E00395FD7 /* AppVersionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6235EDA2FA1587E00395FD7 /* AppVersionService.swift */; }; C6F1BDBB2FA14CC1004886F8 /* ForceUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F1BDBA2FA14CC1004886F8 /* ForceUpdateView.swift */; }; ADDB8A9D2CC0738E02F4CFA5 /* GlassToolbarModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1309285CE00E73FFD36889 /* GlassToolbarModifier.swift */; }; + ADBE83F08B58C5A3D25A8497 /* CategoriesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF650358E3D49F1BFF982BD /* CategoriesView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -326,6 +327,7 @@ C6235EDB2FA1587E00395FD7 /* SemanticVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemanticVersion.swift; sourceTree = ""; }; C6F1BDBA2FA14CC1004886F8 /* ForceUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForceUpdateView.swift; sourceTree = ""; }; AD1309285CE00E73FFD36889 /* GlassToolbarModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassToolbarModifier.swift; sourceTree = ""; }; + ADF650358E3D49F1BFF982BD /* CategoriesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoriesView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -600,6 +602,7 @@ 2C9B4D052C8FCAF20029DF61 /* Components */ = { isa = PBXGroup; children = ( + ADF650358E3D49F1BFF982BD /* CategoriesView.swift */, AD1309285CE00E73FFD36889 /* GlassToolbarModifier.swift */, C6F1BDBA2FA14CC1004886F8 /* ForceUpdateView.swift */, C607480A2F90643200825192 /* ShareSheet.swift */, @@ -903,6 +906,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + ADBE83F08B58C5A3D25A8497 /* CategoriesView.swift in Sources */, ADDB8A9D2CC0738E02F4CFA5 /* GlassToolbarModifier.swift in Sources */, 2E87F6FE2F29A651007C228E /* TransactionConfirmationPopup.swift in Sources */, 2E87F6FC2F270A13007C228E /* NotificationsSettingsView.swift in Sources */, diff --git a/Resell/ViewModels/HomeViewModel.swift b/Resell/ViewModels/HomeViewModel.swift index 9f9e67e..8967264 100644 --- a/Resell/ViewModels/HomeViewModel.swift +++ b/Resell/ViewModels/HomeViewModel.swift @@ -55,6 +55,9 @@ class HomeViewModel: ObservableObject { } @Published var savedItems: [Post] = [] + /// True once `getSavedPosts()` has returned at least once. Until then a post's + /// absence from `savedItems` means "unknown", not "not saved". + @Published private(set) var hasLoadedSavedItems: Bool = false private var allItems: [Post] = [] private var page = 1 @@ -175,6 +178,7 @@ class HomeViewModel: ObservableObject { let postsResponse = try await NetworkManager.shared.getSavedPosts() savedItems = Post.sortPostsByDate(postsResponse.posts) lastSavedFetchTime = Date() + hasLoadedSavedItems = true } catch { NetworkManager.shared.logger.error("Error in HomeViewModel.getSavedPosts: \(error)") } diff --git a/Resell/Views/Components/CachedImageView.swift b/Resell/Views/Components/CachedImageView.swift index c6302ee..9ba30d7 100644 --- a/Resell/Views/Components/CachedImageView.swift +++ b/Resell/Views/Components/CachedImageView.swift @@ -12,7 +12,18 @@ import SwiftUI struct CachedImageView: View { @Binding var isImageLoaded: Bool + @Binding var aspectRatio: CGFloat? let imageURL: URL? + + init( + isImageLoaded: Binding, + imageURL: URL?, + aspectRatio: Binding = .constant(nil) + ) { + self._isImageLoaded = isImageLoaded + self._aspectRatio = aspectRatio + self.imageURL = imageURL + } private let targetSize: CGSize = { let cellWidth = (UIScreen.main.bounds.width - 68) / 2 @@ -29,8 +40,11 @@ struct CachedImageView: View { ) .cacheOriginalImage() .fade(duration: 0.2) - .onSuccess { _ in + .onSuccess { result in isImageLoaded = true + if result.image.size.width > 0 { + aspectRatio = result.image.size.height / result.image.size.width + } } .onFailure { _ in isImageLoaded = false diff --git a/Resell/Views/Components/CategoriesView.swift b/Resell/Views/Components/CategoriesView.swift new file mode 100644 index 0000000..04f0867 --- /dev/null +++ b/Resell/Views/Components/CategoriesView.swift @@ -0,0 +1,43 @@ +// +// CategoriesView.swift +// Resell +// +// Created by Andrew Gao on 9/3/26. +// + +import SwiftUI + +struct CategoriesView: View { + + @EnvironmentObject var router: Router + + var body: some View { + VStack(alignment: .leading) { + Text("Shop By Category") + .font(Constants.Fonts.h2) + .foregroundStyle(Constants.Colors.black) + .padding(.leading, Constants.Spacing.horizontalPadding) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top) { + ForEach(Constants.filters.filter { $0.color != nil }, id: \.id) { filter in + VStack { + CircularFilterButton(filter: filter) { + router.push(.detailedFilter(filter)) + } + + Text(filter.title) + .font(Constants.Fonts.title4) + .frame(width: 80) + .multilineTextAlignment(.center) + .foregroundStyle(Constants.Colors.black) + } + .padding(.trailing, 30) + } + } + .padding(.leading, Constants.Spacing.horizontalPadding) + .padding(.vertical, 1) + } + } + } +} diff --git a/Resell/Views/Components/ProductsGalleryView.swift b/Resell/Views/Components/ProductsGalleryView.swift index 04f53d0..99ada48 100644 --- a/Resell/Views/Components/ProductsGalleryView.swift +++ b/Resell/Views/Components/ProductsGalleryView.swift @@ -35,7 +35,7 @@ struct ProductsGalleryView: View { var body: some View { HStack(alignment: .top, spacing: 20) { - LazyVStack(spacing: 20) { + LazyVStack(spacing: 10) { ForEach(column1, id: \.id) { post in ProductGalleryCell(selectedItem: $selectedItem, post: post, savedCell: false) .onAppear { @@ -44,7 +44,7 @@ struct ProductsGalleryView: View { } } - LazyVStack(spacing: 20) { + LazyVStack(spacing: 10) { ForEach(column2, id: \.id) { post in ProductGalleryCell(selectedItem: $selectedItem, post: post, savedCell: false) .onAppear { @@ -56,10 +56,9 @@ struct ProductsGalleryView: View { .padding(.horizontal, Constants.Spacing.horizontalPadding) .padding(.bottom, Constants.Spacing.horizontalPadding) .onChange(of: selectedItem) { item in - if let selectedItem { - navigateToProductDetails(post: selectedItem) - self.selectedItem = nil - } + guard let item else { return } + navigateToProductDetails(post: item) + selectedItem = nil } } @@ -96,55 +95,84 @@ struct ProductGalleryCell: View { // MARK: Properties @Binding var selectedItem: Post? + @ObservedObject private var homeViewModel = HomeViewModel.shared @State private var isImageLoaded: Bool = false + @State private var imageAspectRatio: CGFloat? + @State private var isSaved: Bool = false let post: Post - let savedCell : Bool - private let cellWidth = (UIScreen.width - 68) / 2 + let savedCell: Bool + private let cellWidth = (UIScreen.width - 46) / 2 // MARK: UI private var isSold: Bool { post.sold == true } + + /// Height/width ratio used while loading and as a fallback. + private static let placeholderAspectRatio: CGFloat = 4.0 / 3.0 + + private var imageHeight: CGFloat { + let ratio = imageAspectRatio ?? Self.placeholderAspectRatio + return cellWidth * ratio + } + + private var categoryLabel: String { + if let name = post.categories?.first?.name, !name.isEmpty { + return name + } + if let category = post.category, !category.isEmpty { + return category + } + return "Other" + } + + private var conditionLabel: String? { + guard let condition = post.condition, !condition.isEmpty else { return nil } + return condition + } + + private var detailsLabel: String { + guard let conditionLabel else { return categoryLabel } + return "\(categoryLabel) • \(conditionLabel)" + } var body: some View { - Button { - selectedItem = post - } label: { VStack(spacing: 0) { - let url = URL(string: post.images.first ?? "") - ZStack { - CachedImageView(isImageLoaded: $isImageLoaded, imageURL: url) - .frame(width: cellWidth, height: (savedCell ? cellWidth - 20 : cellWidth / 0.75)) + Button { + selectedItem = post + } label: { + let url = URL(string: post.images.first ?? "") + ZStack { + CachedImageView( + isImageLoaded: $isImageLoaded, + imageURL: url, + aspectRatio: $imageAspectRatio + ) + .frame(width: cellWidth, height: imageHeight) .clipped() - - // Sold overlay - if isSold { - Rectangle() - .fill(Color.black.opacity(0.5)) - .frame(width: cellWidth, height: (savedCell ? cellWidth - 20 : cellWidth / 0.75)) - - Text("Item Sold") - .font(.custom("Rubik-Medium", size: 16)) - .foregroundColor(.white) - } - } - HStack(spacing: 4) { - Text(post.title) - .font(Constants.Fonts.title3) - .foregroundStyle(isSold ? Constants.Colors.secondaryGray : Constants.Colors.black) - .lineLimit(1) - .truncationMode(.tail) + if isSold { + Rectangle() + .fill(Color.black.opacity(0.5)) + .frame(width: cellWidth, height: imageHeight) - Spacer(minLength: 4) + Text("Item Sold") + .font(.custom("Rubik-Medium", size: 16)) + .foregroundColor(.white) + } + } + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) - Text("$\(post.originalPrice)") - .font(Constants.Fonts.title4) - .foregroundStyle(isSold ? Constants.Colors.secondaryGray : Constants.Colors.black) - .lineLimit(1) - .fixedSize() + HStack(alignment: .top, spacing: 8) { + infoButton + VStack(alignment: .trailing, spacing: 2) { + priceLabel + if !savedCell { saveButton } + } } .padding(8) .background(Constants.Colors.white) @@ -157,7 +185,98 @@ struct ProductGalleryCell: View { RoundedRectangle(cornerRadius: 8) .stroke(Constants.Colors.stroke, lineWidth: 1) } + .onAppear { + loadSavedState() + } + .onReceive(homeViewModel.$savedItems) { savedItems in + isSaved = savedItems.contains(where: { $0.id == post.id }) + } + .onChange(of: post.id) { _ in + imageAspectRatio = nil + isImageLoaded = false + loadSavedState() + } + } + + // MARK: - Private Methods + + private var infoButton: some View { + Button { + selectedItem = post + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(post.title) + .font(Constants.Fonts.title3) + .foregroundStyle(isSold ? Constants.Colors.secondaryGray : Constants.Colors.black) + .lineLimit(1) + + Text(detailsLabel) + .font(Constants.Fonts.subtitle1) + .foregroundStyle(Constants.Colors.secondaryGray) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } + + private var priceLabel: some View { + Text("$\(post.originalPrice)") + .font(Constants.Fonts.title3) + .foregroundStyle(isSold ? Constants.Colors.secondaryGray : Constants.Colors.black) + .lineLimit(1) + } + + private var saveButton: some View { + Button(action: toggleSave) { + Image(systemName: isSaved ? "bookmark.fill" : "bookmark") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Constants.Colors.black) + .frame(width: 14, height: 14) } .buttonStyle(.plain) } + + private func loadSavedState() { + if homeViewModel.savedItems.contains(where: { $0.id == post.id }) { + isSaved = true + return + } + + // `savedItems` holds every saved post, so once it has loaded, absence is + // an answer. Only fall back to a per-post request before that — otherwise + // scrolling a feed fires one request per cell. + guard !homeViewModel.hasLoadedSavedItems else { + isSaved = false + return + } + + Task { + let saved = (try? await NetworkManager.shared.postIsSaved(id: post.id))?.isSaved ?? false + await MainActor.run { + isSaved = saved + } + } + } + + private func toggleSave() { + let newState = !isSaved + isSaved = newState + + Task { + do { + if newState { + _ = try await NetworkManager.shared.savePostByID(id: post.id) + } else { + _ = try await NetworkManager.shared.unsavePostByID(id: post.id) + } + await homeViewModel.toggleLocalSaveStatus(for: post, isSaving: newState) + } catch { + await MainActor.run { + isSaved = !newState + } + NetworkManager.shared.logger.error("Error in ProductGalleryCell.toggleSave: \(error)") + } + } + } }