Skip to content
Open
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
4 changes: 4 additions & 0 deletions Resell.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -326,6 +327,7 @@
C6235EDB2FA1587E00395FD7 /* SemanticVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemanticVersion.swift; sourceTree = "<group>"; };
C6F1BDBA2FA14CC1004886F8 /* ForceUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForceUpdateView.swift; sourceTree = "<group>"; };
AD1309285CE00E73FFD36889 /* GlassToolbarModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassToolbarModifier.swift; sourceTree = "<group>"; };
ADF650358E3D49F1BFF982BD /* CategoriesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoriesView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -600,6 +602,7 @@
2C9B4D052C8FCAF20029DF61 /* Components */ = {
isa = PBXGroup;
children = (
ADF650358E3D49F1BFF982BD /* CategoriesView.swift */,
AD1309285CE00E73FFD36889 /* GlassToolbarModifier.swift */,
C6F1BDBA2FA14CC1004886F8 /* ForceUpdateView.swift */,
C607480A2F90643200825192 /* ShareSheet.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down
4 changes: 4 additions & 0 deletions Resell/ViewModels/HomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)")
}
Expand Down
16 changes: 15 additions & 1 deletion Resell/Views/Components/CachedImageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,18 @@ import SwiftUI
struct CachedImageView: View {

@Binding var isImageLoaded: Bool
@Binding var aspectRatio: CGFloat?
let imageURL: URL?

init(
isImageLoaded: Binding<Bool>,
imageURL: URL?,
aspectRatio: Binding<CGFloat?> = .constant(nil)
) {
self._isImageLoaded = isImageLoaded
self._aspectRatio = aspectRatio
self.imageURL = imageURL
}

private let targetSize: CGSize = {
let cellWidth = (UIScreen.main.bounds.width - 68) / 2
Expand All @@ -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
Expand Down
43 changes: 43 additions & 0 deletions Resell/Views/Components/CategoriesView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
197 changes: 158 additions & 39 deletions Resell/Views/Components/ProductsGalleryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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)
Expand All @@ -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)")
}
}
}
}