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
33 changes: 32 additions & 1 deletion 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 All @@ -201,7 +205,34 @@ class HomeViewModel: ObservableObject {
NetworkManager.shared.logger.error("Sync failed: \(error)")
}
}


/// Whether `post` is saved. Once `savedItems` has loaded it holds every saved
/// post, so absence is an answer; only before that does this ask the backend,
/// otherwise scrolling a feed would fire one request per cell.
func isPostSaved(_ post: Post) async -> Bool {
if savedItems.contains(where: { $0.id == post.id }) {
return true
}
guard !hasLoadedSavedItems else { return false }

do {
return try await NetworkManager.shared.postIsSaved(id: post.id).isSaved
} catch {
NetworkManager.shared.logger.error("Error in HomeViewModel.isPostSaved: \(error)")
return false
}
}

/// Saves or unsaves `post` on the backend, then brings `savedItems` in line.
func setSaved(_ isSaved: Bool, for post: Post) async throws {
if isSaved {
_ = try await NetworkManager.shared.savePostByID(id: post.id)
} else {
_ = try await NetworkManager.shared.unsavePostByID(id: post.id)
}
await toggleLocalSaveStatus(for: post, isSaving: isSaved)
}

/// Clear any applied filter-sheet results and return the home feed to the
/// default "Recent" state. Explicitly resets `filteredItems` to the full
/// cached `allItems` rather than depending on `selectedFilter`'s `didSet`.
Expand Down
18 changes: 17 additions & 1 deletion Resell/Views/Components/CachedImageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,20 @@ import SwiftUI
struct CachedImageView: View {

@Binding var isImageLoaded: Bool
/// Receives the loaded image's height/width ratio, for callers that size
/// themselves to the photo rather than cropping it.
@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 +42,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
53 changes: 53 additions & 0 deletions Resell/Views/Components/CategoriesView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// CategoriesView.swift
// Resell
//
// Created by Andrew Gao on 9/3/26.
//

import SwiftUI

/// "Shop By Category" row: one circular button per product category, each
/// opening that category's browse screen.
struct CategoriesView: View {

// MARK: - Properties

@EnvironmentObject private var router: Router

// MARK: - UI

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.productCategories, id: \.id) { filter in
categoryButton(for: filter)
}
}
.padding(.leading, Constants.Spacing.horizontalPadding)
.padding(.vertical, 1)
}
}
}

private func categoryButton(for filter: FilterCategory) -> some View {
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)
}
}
Loading