From 61476c013a33b943b3d05af3057dfcbf3903a8c7 Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 3 Sep 2026 01:36:52 -0400 Subject: [PATCH] Add the Sell tab: post a listing from one screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the three-step push flow (add button → images → details) with a single form: photos, title and price, category, condition, description, publish. Nothing routes to it yet — the tab bar rework wires it up. The photo well is a carousel rather than a strip, so each image is reviewed at the size it will be posted at, with delete and add controls on the image itself and arrows only where there is somewhere to go. Categories come from `Constants.filters` instead of a hand-written list. The first cut hardcoded seven, which already disagreed with the taxonomy — it was missing Household. Only the "Handmade" → "Homemade" display name is special-cased here. New listings default to "Gently Used" rather than "Never Used": the previous default made the strongest claim on the seller's behalf, which is the wrong way round for a form that people will submit without reading. Left out of the port: a clothing-only "Additional Details" block whose call site was commented out and whose fields did nothing. Worth flagging for a follow-up — `NewListingViewModel.createNewListing()` calls `clear()` on failure as well as success, so a network error silently empties the form. That was survivable when the form was a pushed screen you were about to leave; on a tab root that keeps you where you are, it reads as the app throwing your listing away. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017R8mua9xepzF3mnRETtd6W --- Resell.xcodeproj/project.pbxproj | 12 + Resell/ViewModels/NewListingViewModel.swift | 4 +- Resell/Views/Sell/SellView.swift | 488 ++++++++++++++++++++ 3 files changed, 502 insertions(+), 2 deletions(-) create mode 100644 Resell/Views/Sell/SellView.swift diff --git a/Resell.xcodeproj/project.pbxproj b/Resell.xcodeproj/project.pbxproj index 02766f9..888bd6f 100644 --- a/Resell.xcodeproj/project.pbxproj +++ b/Resell.xcodeproj/project.pbxproj @@ -176,6 +176,7 @@ ADC4811657FC86FA058DBC78 /* ExploreSections.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD61E53786777FDFEDFFBC3F /* ExploreSections.swift */; }; AD0F669E03C5CC5D5095E8FD /* SearchPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7B878D8FDC4D4A0336C061 /* SearchPanel.swift */; }; ADF70ED11386D407EB2BD15F /* ExploreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD6DA1177C5F0B07D6B2E273 /* ExploreView.swift */; }; + ADBF2503F1D2567996BEA15D /* SellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD301494F694E79F7F82961D /* SellView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -346,6 +347,7 @@ AD61E53786777FDFEDFFBC3F /* ExploreSections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreSections.swift; sourceTree = ""; }; AD7B878D8FDC4D4A0336C061 /* SearchPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchPanel.swift; sourceTree = ""; }; AD6DA1177C5F0B07D6B2E273 /* ExploreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreView.swift; sourceTree = ""; }; + AD301494F694E79F7F82961D /* SellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SellView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -602,6 +604,7 @@ 2C9B4D012C8FC7B00029DF61 /* Views */ = { isa = PBXGroup; children = ( + ADA40C4D7287920971907DE7 /* Sell */, ADE4A10188B9356473A077AA /* Explore */, 2CBC6B5E2CB75ACD00C842A4 /* MainTabView.swift */, 2C52E4F22C926CFE0042312C /* MainView.swift */, @@ -779,6 +782,14 @@ path = Explore; sourceTree = ""; }; + ADA40C4D7287920971907DE7 /* Sell */ = { + isa = PBXGroup; + children = ( + AD301494F694E79F7F82961D /* SellView.swift */, + ); + path = Sell; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -941,6 +952,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + ADBF2503F1D2567996BEA15D /* SellView.swift in Sources */, ADF70ED11386D407EB2BD15F /* ExploreView.swift in Sources */, AD0F669E03C5CC5D5095E8FD /* SearchPanel.swift in Sources */, ADC4811657FC86FA058DBC78 /* ExploreSections.swift in Sources */, diff --git a/Resell/ViewModels/NewListingViewModel.swift b/Resell/ViewModels/NewListingViewModel.swift index 21a1576..390afdf 100644 --- a/Resell/ViewModels/NewListingViewModel.swift +++ b/Resell/ViewModels/NewListingViewModel.swift @@ -25,7 +25,7 @@ class NewListingViewModel: ObservableObject { @Published var descriptionText: String = "" @Published var priceText: String = "" @Published var selectedFilter: String = "Clothing" - @Published var selectedCondition: String = "Never Used" + @Published var selectedCondition: String = "Gently Used" @Published var titleText: String = "" // MARK: - Functions @@ -110,7 +110,7 @@ class NewListingViewModel: ObservableObject { descriptionText = "" priceText = "" selectedFilter = "Clothing" - selectedCondition = "Never Used" + selectedCondition = "Gently Used" isLoading = false } } diff --git a/Resell/Views/Sell/SellView.swift b/Resell/Views/Sell/SellView.swift new file mode 100644 index 0000000..1a8f5b0 --- /dev/null +++ b/Resell/Views/Sell/SellView.swift @@ -0,0 +1,488 @@ +// +// SellView.swift +// Resell +// +// Created by Andrew Gao on 9/3/26. +// + +import Flow +import PhotosUI +import SwiftUI + +struct SellView: View { + + // MARK: - Properties + + @EnvironmentObject private var viewModel: NewListingViewModel + + @FocusState private var focusedField: Field? + @State private var currentImageIndex = 0 + + private enum Field { + case title + case price + case description + } + + /// Listing categories, in `Constants.filters` order. A nil color marks a + /// pseudo-category ("Recent") that is not something you can list under. + private let categoryOptions: [String] = Constants.filters.compactMap { + $0.color == nil ? nil : $0.title + } + + /// The backend category name differs from what reads well on the form. + private let categoryDisplayNames = ["Handmade": "Homemade"] + + private let conditionOptions: [(label: String, value: String)] = [ + ("Brand New", "Never Used"), + ("Gently Used", "Gently Used"), + ("Used", "Worn"), + ] + + // MARK: - UI + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + header + imagePicker + primaryFields + categorySection + conditionSection + descriptionField + actionButtons + } + .padding(.horizontal, Constants.Spacing.horizontalPadding) + .padding(.top, 12) + .padding(.bottom, 24) + } + .scrollDismissesKeyboard(.interactively) + .background(Constants.Colors.white) + .navigationBarBackButtonHidden() + .toolbar(.hidden, for: .navigationBar) + .sheet(isPresented: $viewModel.didShowPriceInput) { + PriceInputView( + price: $viewModel.priceText, + isPresented: $viewModel.didShowPriceInput, + titleText: "What price do you want to sell your product?" + ) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(25) + } + .photosPicker( + isPresented: $viewModel.didShowPhotosPicker, + selection: $viewModel.selectedItems, + maxSelectionCount: viewModel.remainingImageSlots, + matching: .images, + photoLibrary: .shared() + ) + .sheet(isPresented: $viewModel.didShowCamera) { + ImagePicker(sourceType: .camera, selectedImages: $viewModel.selectedImages) + } + .confirmationDialog( + "Select Image Source", + isPresented: $viewModel.didShowImageSourceDialog, + titleVisibility: .visible + ) { + Button("Photo Library") { + viewModel.didShowPhotosPicker = true + } + Button("Camera") { + viewModel.didShowCamera = true + } + Button("Cancel", role: .cancel) {} + } + .onChange(of: viewModel.selectedItems) { newItems in + Task { + await viewModel.updateListingImages(newItems: newItems) + } + } + .onChange(of: viewModel.selectedImages.count) { imageCount in + currentImageIndex = min(currentImageIndex, max(0, imageCount - 1)) + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Sell") + .font(Constants.Fonts.h1) + .foregroundStyle(Constants.Colors.black) + + Text("Give the people what they want") + .font(Constants.Fonts.body1) + .foregroundStyle(Constants.Colors.black) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var imagePicker: some View { + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(Constants.Colors.wash.opacity(0.6)) + + if viewModel.selectedImages.isEmpty { + emptyImagePicker + } else { + selectedImageCarousel + } + } + .aspectRatio(385.0 / 439.0, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Constants.Colors.secondaryGray, lineWidth: 0.75) + } + } + + private var emptyImagePicker: some View { + Button { + focusedField = nil + viewModel.didShowImageSourceDialog = true + } label: { + VStack(spacing: 16) { + Image(systemName: "photo.badge.plus") + .font(.system(size: 42, weight: .regular)) + .foregroundStyle(Constants.Colors.black) + + Text("Add images to show off your listing") + .font(Constants.Fonts.subtitle1) + .foregroundStyle(Constants.Colors.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Constants.Colors.tertiaryGray) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Constants.Colors.black, lineWidth: 1) + } + } + } + .buttonStyle(.plain) + } + + private var selectedImageCarousel: some View { + ZStack { + GeometryReader { proxy in + TabView(selection: $currentImageIndex) { + ForEach(viewModel.selectedImages.indices, id: \.self) { index in + Image(uiImage: viewModel.selectedImages[index]) + .resizable() + .scaledToFill() + .frame(width: proxy.size.width, height: proxy.size.height) + .clipped() + .tag(index) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + } + .clipped() + + HStack { + carouselButton(systemName: "chevron.left", direction: -1) + + Spacer() + + carouselButton(systemName: "chevron.right", direction: 1) + } + .padding(.horizontal, 8) + + VStack { + HStack { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.removeImage(at: currentImageIndex) + } + } label: { + Image(systemName: "trash") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Constants.Colors.errorRed) + .frame(width: 36, height: 36) + .background(.ultraThinMaterial, in: Circle()) + } + .buttonStyle(.plain) + + Spacer() + + Button { + viewModel.didShowImageSourceDialog = true + } label: { + Image(systemName: "plus") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Constants.Colors.black) + .frame(width: 36, height: 36) + .background(.ultraThinMaterial, in: Circle()) + } + .buttonStyle(.plain) + .disabled(viewModel.remainingImageSlots == 0) + } + + Spacer() + + Text("\(currentImageIndex + 1) / \(viewModel.selectedImages.count)") + .font(Constants.Fonts.subtitle1) + .foregroundStyle(Constants.Colors.white) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(.black.opacity(0.55), in: Capsule()) + } + .padding(12) + } + } + + private func carouselButton(systemName: String, direction: Int) -> some View { + Button { + let nextIndex = currentImageIndex + direction + guard viewModel.selectedImages.indices.contains(nextIndex) else { return } + withAnimation(.easeInOut(duration: 0.2)) { + currentImageIndex = nextIndex + } + } label: { + Image(systemName: systemName) + .font(.system(size: 28, weight: .medium)) + .foregroundStyle(Constants.Colors.white) + .frame(width: 44, height: 60) + .contentShape(Rectangle()) + .shadow(color: .black.opacity(0.35), radius: 2) + } + .buttonStyle(.plain) + .disabled(!viewModel.selectedImages.indices.contains(currentImageIndex + direction)) + .opacity(viewModel.selectedImages.indices.contains(currentImageIndex + direction) ? 1 : 0) + } + + private var primaryFields: some View { + HStack(alignment: .top, spacing: 12) { + listingField( + label: "Item Name / Title", + placeholder: "Something belongs here...", + text: $viewModel.titleText, + field: .title + ) + + priceField + } + } + + private var priceField: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Price") + .font(Constants.Fonts.body2) + .foregroundStyle(Constants.Colors.black) + + Button { + focusedField = nil + viewModel.didShowPriceInput = true + } label: { + HStack(spacing: 6) { + Text("$") + .font(Constants.Fonts.body2) + .foregroundStyle(Constants.Colors.black) + + Text(viewModel.priceText.isEmpty ? "1" : viewModel.priceText) + .font(Constants.Fonts.subtitle1) + .foregroundStyle( + viewModel.priceText.isEmpty + ? Constants.Colors.inactiveGray + : Constants.Colors.black + ) + + Spacer() + } + .padding(.horizontal, 10) + .frame(width: 102, height: 36) + .background(Constants.Colors.wash) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Constants.Colors.inactiveGray, lineWidth: 1) + } + } + .buttonStyle(.plain) + } + } + + private func listingField( + label: String, + placeholder: String, + text: Binding, + field: Field, + prefix: String? = nil, + width: CGFloat? = nil + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(Constants.Fonts.body2) + .foregroundStyle(Constants.Colors.black) + + HStack(spacing: 6) { + if let prefix { + Text(prefix) + .font(Constants.Fonts.body2) + .foregroundStyle(Constants.Colors.black) + } + + TextField(placeholder, text: text) + .font(Constants.Fonts.subtitle1) + .foregroundStyle(Constants.Colors.black) + .focused($focusedField, equals: field) + .keyboardType(field == .price ? .decimalPad : .default) + } + .padding(.horizontal, 10) + .frame(height: 36) + .background(Constants.Colors.wash) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Constants.Colors.inactiveGray, lineWidth: 1) + } + } + .frame(width: width) + .frame(maxWidth: width == nil ? .infinity : nil, alignment: .leading) + } + + private var categorySection: some View { + formSection(title: "Category") { + HFlow { + ForEach(categoryOptions, id: \.self) { category in + selectionChip( + title: categoryDisplayNames[category] ?? category, + isSelected: viewModel.selectedFilter == category + ) { + viewModel.selectedFilter = category + } + } + } + } + } + + private var conditionSection: some View { + formSection(title: "Condition") { + HFlow { + ForEach(conditionOptions.indices, id: \.self) { index in + let condition = conditionOptions[index] + selectionChip( + title: condition.label, + isSelected: viewModel.selectedCondition == condition.value + ) { + viewModel.selectedCondition = condition.value + } + } + } + } + } + + private func formSection( + title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(Constants.Fonts.subtitle1) + .foregroundStyle(Constants.Colors.black) + + Divider() + .overlay(Constants.Colors.secondaryGray) + + content() + } + } + + private func selectionChip( + title: String, + isSelected: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(title) + .font(Constants.Fonts.subtitle1) + .foregroundStyle( + isSelected + ? Constants.Colors.resellPurple + : Constants.Colors.inactiveGray + ) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + isSelected + ? Constants.Colors.resellPurple.opacity(0.15) + : Constants.Colors.white + ) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke( + isSelected + ? Constants.Colors.resellPurple + : Constants.Colors.secondaryGray, + lineWidth: 1 + ) + } + } + .buttonStyle(.plain) + } + + private var descriptionField: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Description") + .font(Constants.Fonts.subtitle1) + .foregroundStyle(Constants.Colors.black) + + ZStack(alignment: .topLeading) { + if viewModel.descriptionText.isEmpty { + Text("Details that someone buying this should know...") + .font(Constants.Fonts.subtitle1) + .foregroundStyle(Constants.Colors.inactiveGray) + .padding(.horizontal, 10) + .padding(.vertical, 12) + } + + TextEditor(text: $viewModel.descriptionText) + .font(Constants.Fonts.subtitle1) + .foregroundStyle(Constants.Colors.black) + .focused($focusedField, equals: .description) + .scrollContentBackground(.hidden) + .padding(4) + .background(.clear) + } + .frame(minHeight: 80) + .background(Constants.Colors.wash) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Constants.Colors.inactiveGray, lineWidth: 1) + } + } + } + + private var actionButtons: some View { + Button { + focusedField = nil + viewModel.createNewListing() + } label: { + Group { + if viewModel.isLoading { + ProgressView() + .tint(Constants.Colors.white) + } else { + Text("Publish Listing") + } + } + .font(Constants.Fonts.title3) + .foregroundStyle(Constants.Colors.white) + .frame(maxWidth: .infinity, minHeight: 40) + .background(Constants.Colors.tertiaryGray) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Constants.Colors.black, lineWidth: 1) + } + } + .buttonStyle(.plain) + .disabled(!canPublish || viewModel.isLoading) + .opacity(canPublish ? 1 : 0.45) + } + + private var canPublish: Bool { + !viewModel.selectedImages.isEmpty && viewModel.checkInputIsValid() + } +}