diff --git a/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.pbxproj b/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.pbxproj index be25d42..7a94a70 100644 --- a/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.pbxproj +++ b/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.pbxproj @@ -397,6 +397,7 @@ CURRENT_PROJECT_VERSION = 1; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "ShopWise uses your location to show nearby grocery stores."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -428,6 +429,7 @@ CURRENT_PROJECT_VERSION = 1; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "ShopWise uses your location to show nearby grocery stores."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.xcworkspace/xcuserdata/jchang.xcuserdatad/UserInterfaceState.xcuserstate b/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.xcworkspace/xcuserdata/jchang.xcuserdatad/UserInterfaceState.xcuserstate index 5660a38..e983ceb 100644 Binary files a/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.xcworkspace/xcuserdata/jchang.xcuserdatad/UserInterfaceState.xcuserstate and b/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.xcworkspace/xcuserdata/jchang.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/SWFrontUI/ShopwiseFrontEndUI/AppToolbar.swift b/SWFrontUI/ShopwiseFrontEndUI/AppToolbar.swift new file mode 100644 index 0000000..311c036 --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/AppToolbar.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct AppToolbar: ViewModifier { + func body(content: Content) -> some View { + content + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 14) { + NavigationLink { + SettingsView() + } label: { + Image(systemName: "gearshape") + } + + NavigationLink { + ProfileView() + } label: { + Image(systemName: "person.crop.circle") + } + } + } + } + } +} + +extension View { + func appToolbar() -> some View { + self.modifier(AppToolbar()) + } +} + +extension View { + @ViewBuilder + func when(_ condition: Bool, transform: (Self) -> some View) -> some View { + if condition { + transform(self) + } else { + self + } + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/AuthGateView.swift b/SWFrontUI/ShopwiseFrontEndUI/AuthGateView.swift index 92b7a4e..a059b6d 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/AuthGateView.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/AuthGateView.swift @@ -10,17 +10,48 @@ import SwiftUI struct AuthGateView: View { @EnvironmentObject var auth: AuthManager + @State private var isCheckingPreferences = false + @State private var hasCompletedOnboarding = false + var body: some View { Group { - if auth.isSignedIn { + if !auth.isSignedIn { + AuthView() + } else if isCheckingPreferences { + ProgressView("Loading...") + } else if hasCompletedOnboarding { ContentView() } else { - AuthView() + NavigationStack { + OnboardingSurveyView { + hasCompletedOnboarding = true + } + } } } .task { - // Restore session if tokens exist await auth.restoreSession() } + .task(id: auth.isSignedIn) { + await checkOnboardingStatus() + } + } + + private func checkOnboardingStatus() async { + guard auth.isSignedIn else { + hasCompletedOnboarding = false + isCheckingPreferences = false + return + } + + isCheckingPreferences = true + defer { isCheckingPreferences = false } + + do { + let prefs = try await auth.fetchUserPreferences() + hasCompletedOnboarding = (prefs != nil) + } catch { + hasCompletedOnboarding = false + } } } diff --git a/SWFrontUI/ShopwiseFrontEndUI/AuthManager.swift b/SWFrontUI/ShopwiseFrontEndUI/AuthManager.swift index 4d88d36..9353e75 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/AuthManager.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/AuthManager.swift @@ -68,6 +68,7 @@ final class AuthManager: ObservableObject { @Published var errorMessage: String? = nil @Published var isSignedIn: Bool = false @Published var userEmail: String? = nil + @Published var userID: String? = nil // Tokens private(set) var accessToken: String? = nil @@ -114,6 +115,7 @@ final class AuthManager: ObservableObject { let session = try JSONDecoder().decode(SupabaseSession.self, from: data) self.applySession(session) self.userEmail = email + self.userID = session.user?.id } } @@ -148,6 +150,7 @@ final class AuthManager: ObservableObject { !session.access_token.isEmpty { self.applySession(session) self.userEmail = email + self.userID = session.user?.id } else { self.userEmail = email throw AuthError.emailConfirmationRequired @@ -198,11 +201,13 @@ final class AuthManager: ObservableObject { let user = try JSONDecoder().decode(SupabaseUser.self, from: data) self.userEmail = user.email self.isSignedIn = true + self.userID = user.id } catch { // token might be expired -> try refresh if let refreshed = try? await refreshSession() { self.userEmail = refreshed.user?.email self.isSignedIn = true + self.userID = refreshed.user?.id } else { clearSession() } @@ -250,6 +255,7 @@ final class AuthManager: ObservableObject { private func applySession(_ session: SupabaseSession) { self.accessToken = session.access_token self.refreshToken = session.refresh_token + self.userID = session.user?.id saveTokensToStorage() self.isSignedIn = true } @@ -258,6 +264,7 @@ final class AuthManager: ObservableObject { self.accessToken = nil self.refreshToken = nil self.userEmail = nil + self.userID = nil self.isSignedIn = false deleteTokensFromStorage() } @@ -266,7 +273,8 @@ final class AuthManager: ObservableObject { url: URL, method: String, jsonBody: T?, - bearerToken: String? + bearerToken: String?, + extraHeaders: [String: String] = [:] ) async throws -> (Data, HTTPURLResponse) { var req = URLRequest(url: url) @@ -278,6 +286,10 @@ final class AuthManager: ObservableObject { req.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") } + for (key, value) in extraHeaders { + req.setValue(value, forHTTPHeaderField: key) + } + if let jsonBody { req.httpBody = try JSONEncoder().encode(jsonBody) } @@ -412,3 +424,85 @@ extension AuthManager { return try JSONDecoder().decode([WalmartItem].self, from: data) } } + +//Onboard survey fetch/save +struct UserPreferencesRow: Codable { + let user_id: String + let diet_preferences: [String] + let allergies: [String] +} + +struct UserPreferencesUpsertBody: Codable { + let user_id: String + let diet_preferences: [String] + let allergies: [String] +} + +extension AuthManager { + func fetchUserPreferences() async throws -> UserPreferences? { + guard let token = accessToken, let uid = userID else { + throw URLError(.userAuthenticationRequired) + } + + let base = supabaseURL + .appendingPathComponent("rest/v1") + .appendingPathComponent("user_preferences") + + var comps = URLComponents(url: base, resolvingAgainstBaseURL: false)! + comps.queryItems = [ + URLQueryItem(name: "select", value: "user_id,diet_preferences,allergies"), + URLQueryItem(name: "user_id", value: "eq.\(uid)"), + URLQueryItem(name: "limit", value: "1") + ] + + guard let url = comps.url else { throw URLError(.badURL) } + + let (data, _) = try await request( + url: url, + method: "GET", + jsonBody: Optional.none, + bearerToken: token + ) + + let rows = try JSONDecoder().decode([UserPreferencesRow].self, from: data) + guard let row = rows.first else { return nil } + + return UserPreferences( + dietPreferences: row.diet_preferences, + allergies: row.allergies + ) + } + + func saveUserPreferences(_ preferences: UserPreferences) async throws { + guard let token = accessToken, let uid = userID else { + throw URLError(.userAuthenticationRequired) + } + + let base = supabaseURL + .appendingPathComponent("rest/v1") + .appendingPathComponent("user_preferences") + + var comps = URLComponents(url: base, resolvingAgainstBaseURL: false)! + comps.queryItems = [ + URLQueryItem(name: "on_conflict", value: "user_id") + ] + + guard let url = comps.url else { throw URLError(.badURL) } + + let body = UserPreferencesUpsertBody( + user_id: uid, + diet_preferences: preferences.dietPreferences, + allergies: preferences.allergies + ) + + _ = try await request( + url: url, + method: "POST", + jsonBody: body, + bearerToken: token, + extraHeaders: [ + "Prefer": "resolution=merge-duplicates,return=representation" + ] + ) + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/CartStore.swift b/SWFrontUI/ShopwiseFrontEndUI/CartStore.swift index 8cc1cde..267f85d 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/CartStore.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/CartStore.swift @@ -3,13 +3,13 @@ import Combine // Simple cart line item model (separate from your SwiftData Item model) struct CartLineItem: Identifiable, Hashable { - let id: UUID + let id: String let name: String let unit: String let price: Double var quantity: Int - init(id: UUID = UUID(), name: String, unit: String, price: Double, quantity: Int = 1) { + init(id: String = String(), name: String, unit: String, price: Double, quantity: Int = 1) { self.id = id self.name = name self.unit = unit @@ -23,7 +23,6 @@ struct CartLineItem: Identifiable, Hashable { final class CartStore: ObservableObject { @Published private(set) var items: [CartLineItem] = [] - //amount of cart items var itemCount: Int { items.reduce(0) { $0 + $1.quantity } } @@ -32,11 +31,27 @@ final class CartStore: ObservableObject { items.reduce(0) { $0 + $1.lineTotal } } - func add(name: String, unit: String, price: Double) { - if let idx = items.firstIndex(where: { $0.name == name && $0.unit == unit && $0.price == price }) { + func add(product: Product) { + add(id: product.id, name: product.name, unit: product.unit, price: product.price) + } + + func add(ingredient: Ingredient) { + add(id: ingredient.id, name: ingredient.name, unit: ingredient.unit, price: ingredient.price) + } + + func add(id: String, name: String, unit: String, price: Double) { + if let idx = items.firstIndex(where: { $0.id == id }) { items[idx].quantity += 1 } else { - items.append(CartLineItem(name: name, unit: unit, price: price)) + items.append( + CartLineItem( + id: id, + name: name, + unit: unit, + price: price, + quantity: 1 + ) + ) } } diff --git a/SWFrontUI/ShopwiseFrontEndUI/CartView.swift b/SWFrontUI/ShopwiseFrontEndUI/CartView.swift new file mode 100644 index 0000000..f7d5414 --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/CartView.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct CartView: View { + @State private var showShoppingList = false + @EnvironmentObject private var cartStore: CartStore + + var body: some View { + List { + Section("Items") { + if cartStore.items.isEmpty { + Text("Cart is empty") + .foregroundStyle(.secondary) + } else { + ForEach(cartStore.items) { item in + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(item.name) + .font(.headline) + Text(item.unit) + .font(.subheadline) + .foregroundStyle(.secondary) + Text(String(format: "$%.2f", item.price)) + .foregroundStyle(.secondary) + } + + Spacer() + + HStack(spacing: 10) { + Button { + cartStore.decrement(item) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + + Text("\(item.quantity)") + .font(.headline) + .frame(minWidth: 24) + + Button { + cartStore.increment(item) + } label: { + Image(systemName: "plus.circle") + } + .buttonStyle(.borderless) + } + } + .padding(.vertical, 4) + } + .onDelete { indexSet in + for idx in indexSet { + cartStore.remove(cartStore.items[idx]) + } + } + } + } + + Section("Summary") { + HStack { + Text("Total") + Spacer() + Text(String(format: "$%.2f", cartStore.total)) + .foregroundStyle(.secondary) + } + } + + Section { + Button { + showShoppingList = true + } label: { + Text("Checkout") + .frame(maxWidth: .infinity) + } + .disabled(cartStore.items.isEmpty) + } + } + .navigationTitle("Cart") + .appToolbar() + .navigationDestination(isPresented: $showShoppingList) { + ShoppingListView() + } + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/ContentView.swift b/SWFrontUI/ShopwiseFrontEndUI/ContentView.swift index f1be202..7134f7a 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/ContentView.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/ContentView.swift @@ -7,684 +7,34 @@ import SwiftUI import SwiftData -import MapKit -import CoreLocation +struct ContentView: View { + @StateObject private var cartStore = CartStore() -struct AppToolbar: ViewModifier { - func body(content: Content) -> some View { - content - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - HStack(spacing: 14) { - NavigationLink { - SettingsView() - } label: { - Image(systemName: "gearshape") - } - - NavigationLink { - ProfileView() - } label: { - Image(systemName: "person.crop.circle") - } - } - } - } - } -} - -extension View { - func appToolbar() -> some View { - self.modifier(AppToolbar()) - } -} - -extension View { - @ViewBuilder - func when(_ condition: Bool, transform: (Self) -> some View) -> some View { - if condition { - transform(self) - } else { - self - } - } -} - -struct ProfileView: View { - @EnvironmentObject var auth: AuthManager - - var body: some View { - List { - - Section("Account") { - - HStack { - Image(systemName: "person.circle.fill") - .font(.system(size: 36)) - .foregroundStyle(.blue) - - VStack(alignment: .leading) { - Text(auth.userEmail ?? "No email") - .font(.headline) - - Text("Signed in") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 6) - } - - Section { - Button(role: .destructive) { - Task { await auth.signOut() } - } label: { - Text("Sign Out") - } - } - } - .navigationTitle("Profile") - } -} - -struct SettingsView: View { - var body: some View { - List { - Section("Preferences") { - Text("Settings options will go here") - .foregroundStyle(.secondary) - } - } - .navigationTitle("Settings") - } -} - - -struct SearchView: View { - @EnvironmentObject private var auth: AuthManager - @EnvironmentObject private var cartStore: CartStore - - @State private var query = "" - @State private var selectedFilter: String = "All" - @State private var results: [WalmartItem] = [] - @State private var isLoading = false - @State private var errorText: String? = nil - - @State private var pageSize = 50 - @State private var offset = 0 - - @State private var isLoadingMore = false - @State private var hasMore = true - - private let filters = ["All", "Ingredients", "Non-Ingredients"] - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - - // Title - Text("ShopWise") - .font(.system(size: 34, weight: .bold)) - .padding(.top, 6) - - // Search bar (custom like your right screenshot) - HStack(spacing: 10) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - - TextField("Search products...", text: $query) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - - if !query.isEmpty { - Button { - query = "" - searchItems(reset: true) - } label: { - Image(systemName: "xmark") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(.secondary) - .padding(10) - .background(.ultraThinMaterial) - .clipShape(Circle()) - } - } - } - .padding(.horizontal, 14) - .padding(.vertical, 12) - .background(Color(.systemGray6)) - .clipShape(RoundedRectangle(cornerRadius: 18)) - - // Category chips row - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 10) { - ForEach(filters, id: \.self) { f in - SearchCategoryChip(title: f, isSelected: selectedFilter == f) { - selectedFilter = f - searchItems(reset: true) // ✅ ADD HERE - } - } - } - .padding(.vertical, 2) - } - - // Content - if isLoading { - HStack { - Spacer() - ProgressView("Loading…") - Spacer() - } - .padding(.top, 24) - } else if let errorText { - Text(errorText) - .foregroundStyle(.red) - .padding(.top, 10) - } else { - LazyVStack(spacing: 14) { - ForEach(Array(filteredResults.enumerated()), id: \.element.id) { index, item in - ProductCard( - imageURL: bestImageURL(for: item), - title: item.name, - unit: unitText(for: item), - priceText: formatPrice(item.retail_price), - onAdd: { - cartStore.add(name: item.name, unit: unitText(for: item) ?? "", price: item.retail_price ?? 0) - } - ) - .onAppear { - // When the LAST item appears, load more - if index == filteredResults.count - 1 { - searchItems(reset: false) - } - } - } } - .padding(.top, 4) - } - - Spacer(minLength: 20) - } - .padding(.horizontal, 16) - .padding(.bottom, 20) - } - .background(Color(.systemBackground)) - .navigationBarTitleDisplayMode(.inline) - .appToolbar() // <-- uses your existing Settings/Profile toolbar - .onChange(of: query) { _, _ in - searchItems(reset: true) - } - .task { - searchItems(reset: true) - } - } - - // MARK: - Filtering - private var filteredResults: [WalmartItem] { - switch selectedFilter { - case "Ingredients": - return results.filter { $0.ingredient == true } - case "Non-Ingredients": - return results.filter { $0.ingredient == false } - default: - return results - } - } - - // MARK: - Helpers (image/price/unit) - private func bestImageURL(for item: WalmartItem) -> URL? { - if let s = item.thumbnailImage, let url = URL(string: s), !s.isEmpty { return url } - if let s = item.mediumImage, let url = URL(string: s), !s.isEmpty { return url } - if let s = item.largeImage, let url = URL(string: s), !s.isEmpty { return url } - return nil - } - - private func formatPrice(_ p: Double?) -> String { - guard let p else { return "Price unavailable" } - return String(format: "$%.2f", p) - } - - private func unitText(for item: WalmartItem) -> String? { - // Your Supabase data doesn't include a unit column. - // If classifiers contains something unit-like, show it; otherwise hide. - let cls = (item.classifiers ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if cls.isEmpty { return nil } - return cls - } - - // MARK: - Supabase fetch - private func searchItems(reset: Bool = true) { - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - - // If we're already loading more, don't double-fire - if isLoadingMore { return } - - if reset { - offset = 0 - results = [] - hasMore = true - } else { - // if we already know there are no more rows, stop - if !hasMore { return } - } - - if reset { - isLoading = true - } else { - isLoadingMore = true - } - errorText = nil - - Task { - do { - let ingredientOnly: Bool? = { - switch selectedFilter { - case "Ingredients": return true - case "Non-Ingredients": return false - default: return nil - } - }() - - let newItems = try await auth.fetchWalmartItems( - search: trimmed.isEmpty ? nil : trimmed, - ingredientOnly: ingredientOnly, // ✅ NEW - limit: pageSize, - offset: offset - ) - - await MainActor.run { - // Append results - results.append(contentsOf: newItems) - - // Move offset forward - offset += newItems.count - - // If we got fewer than pageSize, we reached the end - if newItems.count < pageSize { hasMore = false } - - isLoading = false - isLoadingMore = false - } - } catch { - await MainActor.run { - errorText = "Search failed: \(error.localizedDescription)" - isLoading = false - isLoadingMore = false - } - } - } - } -} - - -struct SearchCategoryChip: View { - let title: String - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(title) - .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(isSelected ? Color.blue.opacity(0.18) : Color(.systemGray6)) - .foregroundStyle(isSelected ? Color.blue : Color.primary) - .clipShape(Capsule()) - } - .buttonStyle(.plain) - } -} - -struct ProductCard: View { - let imageURL: URL? - let title: String - let unit: String? - let priceText: String - let onAdd: () -> Void - - var body: some View { - HStack(spacing: 14) { - AsyncImage(url: imageURL) { phase in - switch phase { - case .empty: - ProgressView() - .frame(width: 74, height: 74) - case .success(let image): - image - .resizable() - .scaledToFill() - .frame(width: 74, height: 74) - .clipped() - case .failure: - Image(systemName: "photo") - .frame(width: 74, height: 74) - .foregroundStyle(.secondary) - @unknown default: - EmptyView() - .frame(width: 74, height: 74) - } - } - .background(Color(.systemGray6)) - .clipShape(RoundedRectangle(cornerRadius: 16)) - - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.headline) - .lineLimit(2) - - if let unit, !unit.isEmpty { - Text(unit) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Text(priceText) - .font(.headline) - } - - Spacer() - - Button(action: onAdd) { - HStack(spacing: 8) { - Image(systemName: "cart.badge.plus") - Text("Add") - } - .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .foregroundStyle(.white) - .background(Color.blue) - .clipShape(Capsule()) - } - .buttonStyle(.plain) - } - .padding(14) - .background(Color(.systemGray6).opacity(0.65)) - .clipShape(RoundedRectangle(cornerRadius: 18)) - } -} - -struct MapView: View { - @State private var position: MapCameraPosition = .region( - MKCoordinateRegion( - center: CLLocationCoordinate2D(latitude: 33.978194, longitude: -117.367861), - span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) - ) - ) - - private struct DroppedPin: Identifiable { - let id = UUID() - let coordinate: CLLocationCoordinate2D - } - @State private var droppedPins: [DroppedPin] = [] - - var body: some View { - List { - Section() { - CardContainer { - MapReader { proxy in - Map(position: $position) { - ForEach(droppedPins) { pin in - Marker("Selected", coordinate: pin.coordinate) - } - } - .frame(height: 260) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - .onTapGesture { point in - if let coord = proxy.convert(point, from: .local) { - droppedPins.append(DroppedPin(coordinate: coord)) - } - } - } - } - .listRowInsets(EdgeInsets()) - } - - Section("Nearby Grocery Stores") { - HStack { - Image(systemName: "mappin.circle.fill") - VStack(alignment: .leading) { - Text("Walmart Supercenter") - Text("2.5 mi") - .foregroundStyle(.secondary) - } - } - - HStack { - Image(systemName: "mappin.circle.fill") - VStack(alignment: .leading) { - Text("Ralph's") - Text("4.2 mi") - .foregroundStyle(.secondary) - } - Spacer() - } - } - } - .navigationTitle("ShopWise") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - HStack(spacing: 14) { - NavigationLink { SettingsView() } label: { Image(systemName: "gearshape") } - NavigationLink { ProfileView() } label: { Image(systemName: "person.crop.circle") } - } - } - } - } -} - -struct CartView: View { - @EnvironmentObject private var cartStore: CartStore - - var body: some View { - List { - Section("Items") { - if cartStore.items.isEmpty { - Text("Cart is empty") - .foregroundStyle(.secondary) - } else { - ForEach(cartStore.items) { item in - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(item.name) - .font(.headline) - Text(item.unit) - .font(.subheadline) - .foregroundStyle(.secondary) - Text(String(format: "$%.2f", item.price)) - .foregroundStyle(.secondary) - } - - Spacer() - - HStack(spacing: 10) { - Button { - cartStore.decrement(item) - } label: { - Image(systemName: "minus.circle") - } - .buttonStyle(.borderless) // ✅ - - Text("\(item.quantity)") - .font(.headline) - .frame(minWidth: 24) - - Button { - cartStore.increment(item) - } label: { - Image(systemName: "plus.circle") - } - .buttonStyle(.borderless) // ✅ - } - } - .padding(.vertical, 4) - } - .onDelete { indexSet in - for idx in indexSet { - cartStore.remove(cartStore.items[idx]) - } - } - } - } - - Section("Summary") { - HStack { - Text("Total") - Spacer() - Text(String(format: "$%.2f", cartStore.total)) - .foregroundStyle(.secondary) - } - } - - Section { - Button { - // placeholder: checkout - } label: { - Text("Checkout") - .frame(maxWidth: .infinity) - } - .disabled(cartStore.items.isEmpty) - } - } - .navigationTitle("Cart") - .appToolbar() - } -} - -struct Recipe: Identifiable, Hashable { - let id = UUID() - let name: String - let difficulty: String - let minutes: Int - let ingredients: [String] -} - -struct RecipeView: View { - @State private var query = "" - @State private var expandedID: Recipe.ID? = nil //which recipe is expanded - - private let recipes: [Recipe] = [ - Recipe( - name: "Spaghetti", - difficulty: "Easy", - minutes: 25, - ingredients: ["Spaghetti pasta", "Marinara sauce", "Garlic", "Onion", "Olive oil", "Parmesan"] - ), - Recipe( - name: "Orange Chicken", - difficulty: "Medium", - minutes: 35, - ingredients: ["Chicken breast", "Cornstarch", "Orange juice", "Soy sauce", "Honey", "Garlic", "Ginger", "Rice"] - ) - ] - - private var filtered: [Recipe] { - if query.isEmpty { return recipes } - return recipes.filter { $0.name.localizedCaseInsensitiveContains(query) } - } - - var body: some View { - List { - ForEach(filtered) { recipe in - Section { - //Tappable card that expands/collapses - Button { - withAnimation(.snappy) { - expandedID = (expandedID == recipe.id) ? nil : recipe.id - } - } label: { - CardContainer { - HStack(spacing: 12) { - Circle() - .fill(Color(.systemGray5)) - .frame(width: 54, height: 54) - .overlay( - Image(systemName: "fork.knife") - .foregroundStyle(.secondary) - ) - - VStack(alignment: .leading, spacing: 4) { - Text(recipe.name) - .font(.headline) - Text("\(recipe.difficulty) • \(recipe.minutes) min") - .font(.subheadline) - .foregroundStyle(.secondary) - } - - Spacer() - - Image(systemName: expandedID == recipe.id ? "chevron.up" : "chevron.down") - .foregroundStyle(.secondary) - } - } - } - .buttonStyle(.plain) - - // Dropdown ingredients(replace hardcoded data) - if expandedID == recipe.id { - CardContainer { - Text("Ingredients") - .font(.headline) - - ForEach(recipe.ingredients, id: \.self) { item in - HStack(spacing: 10) { - Image(systemName: "checkmark.circle") - .foregroundStyle(.secondary) - Text(item) - } - .padding(.vertical, 2) - } - - Button { - // placeholder: add ingredients to cart - } label: { - Label("Add Ingredients to Cart", systemImage: "cart.badge.plus") - .frame(maxWidth: .infinity) - .foregroundStyle(.white) - } - .buttonStyle(.borderedProminent) - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - } - } - .listStyle(.plain) - .navigationTitle("ShopWise") - .searchable(text: $query, prompt: "Search recipes…") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - HStack(spacing: 14) { - NavigationLink { SettingsView() } label: { Image(systemName: "gearshape") } - NavigationLink { ProfileView() } label: { Image(systemName: "person.crop.circle") } - } - } - } + enum Tab: Hashable { + case search, recipe, map, cart } -} - -struct ContentView: View { - @StateObject private var cartStore = CartStore() + @State private var selectedTab: Tab = .search var body: some View { - TabView { + TabView(selection: $selectedTab) { NavigationStack { SearchView() } .tabItem { Label("Search", systemImage: "magnifyingglass") } + .tag(Tab.search) NavigationStack { RecipeView() } .tabItem { Label("Recipe", systemImage: "book") } + .tag(Tab.recipe) NavigationStack { MapView() } .tabItem { Label("Map", systemImage: "map") } + .tag(Tab.map) NavigationStack { CartView() } .tabItem { Label("Cart", systemImage: "cart") } - .when(cartStore.itemCount > 0) { view in - view.badge(cartStore.itemCount) - } + .tag(Tab.cart) + .badge(cartStore.itemCount) } .environmentObject(cartStore) } @@ -700,3 +50,4 @@ struct ContentView: View { AuthView() .environmentObject(AuthManager()) } + diff --git a/SWFrontUI/ShopwiseFrontEndUI/Ingredient.swift b/SWFrontUI/ShopwiseFrontEndUI/Ingredient.swift new file mode 100644 index 0000000..76813ea --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/Ingredient.swift @@ -0,0 +1,26 @@ +import Foundation + +struct Ingredient: Identifiable, Codable, Hashable { + let id: String + let name: String + let unit: String + let price: Double + let imageURL: String? + let imageName: String? + + init( + id: String, + name: String, + unit: String, + price: Double, + imageURL: String? = nil, + imageName: String? = nil + ) { + self.id = id + self.name = name + self.unit = unit + self.price = price + self.imageURL = imageURL + self.imageName = imageName + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/LocationManager.swift b/SWFrontUI/ShopwiseFrontEndUI/LocationManager.swift new file mode 100644 index 0000000..e3d3753 --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/LocationManager.swift @@ -0,0 +1,48 @@ +import Foundation +import CoreLocation +import Combine + +final class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { + private let manager = CLLocationManager() + + @Published var authorizationStatus: CLAuthorizationStatus = .notDetermined + @Published var userLocation: CLLocationCoordinate2D? + + override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyBest + } + + func requestPermission() { + manager.requestWhenInUseAuthorization() + } + + func startUpdating() { + manager.startUpdatingLocation() + } + + func stopUpdating() { + manager.stopUpdatingLocation() + } + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + authorizationStatus = manager.authorizationStatus + + switch manager.authorizationStatus { + case .authorizedWhenInUse, .authorizedAlways: + startUpdating() + case .denied, .restricted: + stopUpdating() + case .notDetermined: + break + @unknown default: + break + } + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let last = locations.last else { return } + userLocation = last.coordinate + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/MapView.swift b/SWFrontUI/ShopwiseFrontEndUI/MapView.swift new file mode 100644 index 0000000..d438a9a --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/MapView.swift @@ -0,0 +1,133 @@ +import SwiftUI +import MapKit +import CoreLocation + +struct MapView: View { + @StateObject private var locationManager = LocationManager() + + @State private var position: MapCameraPosition = .region( + MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 33.978194, longitude: -117.367861), + span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) + ) + ) + + @State private var stores: [StoreLocation] = [ + StoreLocation( + id: "store_walmart", + name: "Walmart Supercenter", + latitude: 33.988194, + longitude: -117.361861, + address: nil, + chain: "Walmart" + ), + StoreLocation( + id: "store_ralphs", + name: "Ralphs", + latitude: 33.970194, + longitude: -117.363861, + address: nil, + chain: "Ralphs" + ) + ] + + var body: some View { + List { + Section { + CardContainer { + Map(position: $position) { + UserAnnotation() + + ForEach(stores) { store in + Marker(store.name, coordinate: store.coordinate) + } + } + .frame(height: 260) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .listRowInsets(EdgeInsets()) + } + + Section("Nearby Grocery Stores") { + ForEach(stores) { store in + Button { + position = .region( + MKCoordinateRegion( + center: store.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) + ) + ) + } label: { + HStack { + Image(systemName: "mappin.circle.fill") + + VStack(alignment: .leading) { + Text(store.name) + Text(store.chain ?? "Store") + .foregroundStyle(.secondary) + .font(.subheadline) + } + + Spacer() + } + } + .buttonStyle(.plain) + } + } + + Section("Location") { + Button { + centerOnUser() + } label: { + Label("Center on Me", systemImage: "location.fill") + .frame(maxWidth: .infinity) + } + .disabled(locationManager.userLocation == nil) + + HStack { + Text("Permission") + Spacer() + Text(locationStatusText) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("ShopWise") + .appToolbar() + .onAppear { + locationManager.requestPermission() + } + .onChange(of: locationManager.userLocation?.latitude ?? 0) { _, newLatitude in + guard newLatitude != 0 else { return } + centerOnUser() + } + } + + private var locationStatusText: String { + switch locationManager.authorizationStatus { + case .notDetermined: + return "Not Requested" + case .restricted: + return "Restricted" + case .denied: + return "Denied" + case .authorizedAlways: + return "Allowed" + case .authorizedWhenInUse: + return "Allowed" + @unknown default: + return "Unknown" + } + } + + private func centerOnUser() { + guard let user = locationManager.userLocation else { return } + + position = .region( + MKCoordinateRegion( + center: user, + span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) + ) + ) + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/OnboardingSurveyView.swift b/SWFrontUI/ShopwiseFrontEndUI/OnboardingSurveyView.swift new file mode 100644 index 0000000..ef884db --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/OnboardingSurveyView.swift @@ -0,0 +1,139 @@ +import SwiftUI + +struct OnboardingSurveyView: View { + @EnvironmentObject var auth: AuthManager + @Environment(\.dismiss) private var dismiss + + @State private var selectedDiets: Set = [] + @State private var selectedAllergies: Set = [] + @State private var isSaving = false + @State private var errorMessage: String? + + let dietOptions = [ + "Vegetarian", "Vegan", "Pescatarian", + "Keto", "Gluten-Free", "Dairy-Free", + "Halal", "Kosher" + ] + + let allergyOptions = [ + "Peanuts", "Tree Nuts", "Dairy", "Eggs", + "Soy", "Wheat", "Shellfish", "Fish", "Sesame" + ] + + var onComplete: (() -> Void)? = nil + + var body: some View { + List { + Section("Diet Preferences") { + ForEach(dietOptions, id: \.self) { option in + PreferenceRow( + title: option, + isSelected: selectedDiets.contains(option) + ) { + toggle(option, in: &selectedDiets) + } + } + } + + Section("Allergies") { + ForEach(allergyOptions, id: \.self) { option in + PreferenceRow( + title: option, + isSelected: selectedAllergies.contains(option) + ) { + toggle(option, in: &selectedAllergies) + } + } + } + + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + + Section { + Button { + Task { + await savePreferences() + } + } label: { + if isSaving { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("Save Preferences") + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(isSaving) + } + } + .navigationTitle("Your Preferences") + .task { + await loadPreferences() + } + } + + private func toggle(_ value: String, in set: inout Set) { + if set.contains(value) { + set.remove(value) + } else { + set.insert(value) + } + } + + private func loadPreferences() async { + do { + if let prefs = try await auth.fetchUserPreferences() { + selectedDiets = Set(prefs.dietPreferences) + selectedAllergies = Set(prefs.allergies) + } + } catch { + errorMessage = error.localizedDescription + } + } + + private func savePreferences() async { + isSaving = true + errorMessage = nil + defer { isSaving = false } + + do { + let prefs = UserPreferences( + dietPreferences: Array(selectedDiets).sorted(), + allergies: Array(selectedAllergies).sorted() + ) + + try await auth.saveUserPreferences(prefs) + + if let onComplete { + onComplete() + } else { + dismiss() + } + } catch { + errorMessage = error.localizedDescription + } + } +} + +struct PreferenceRow: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Text(title) + Spacer() + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? .blue : .secondary) + } + } + .buttonStyle(.plain) + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/Product.swift b/SWFrontUI/ShopwiseFrontEndUI/Product.swift new file mode 100644 index 0000000..378e4be --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/Product.swift @@ -0,0 +1,29 @@ +import Foundation + +struct Product: Identifiable, Codable, Hashable { + let id: String + let name: String + let category: String + let unit: String + let price: Double + let imageURL: String? + let imageName: String? + + init( + id: String, + name: String, + category: String, + unit: String, + price: Double, + imageURL: String? = nil, + imageName: String? = nil + ) { + self.id = id + self.name = name + self.category = category + self.unit = unit + self.price = price + self.imageURL = imageURL + self.imageName = imageName + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/ProfileView.swift b/SWFrontUI/ShopwiseFrontEndUI/ProfileView.swift new file mode 100644 index 0000000..ab5aa69 --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/ProfileView.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct ProfileView: View { + @EnvironmentObject var auth: AuthManager + + var body: some View { + List { + Section("Account") { + HStack { + Image(systemName: "person.circle.fill") + .font(.system(size: 36)) + .foregroundStyle(.blue) + + VStack(alignment: .leading) { + Text(auth.userEmail ?? "No email") + .font(.headline) + + Text("Signed in") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 6) + } + + Section("Preferences") { + NavigationLink { + OnboardingSurveyView() + } label: { + Label("Edit Diet & Allergies", systemImage: "slider.horizontal.3") + } + } + + Section { + Button(role: .destructive) { + Task { await auth.signOut() } + } label: { + Text("Sign Out") + } + } + } + .navigationTitle("Profile") + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/Recipe.swift b/SWFrontUI/ShopwiseFrontEndUI/Recipe.swift new file mode 100644 index 0000000..320d317 --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/Recipe.swift @@ -0,0 +1,29 @@ +import Foundation + +struct Recipe: Identifiable, Codable, Hashable { + let id: String + let name: String + let difficulty: String + let minutes: Int + let imageURL: String? + let imageName: String? + let ingredients: [Ingredient] + + init( + id: String, + name: String, + difficulty: String, + minutes: Int, + imageURL: String? = nil, + imageName: String? = nil, + ingredients: [Ingredient] + ) { + self.id = id + self.name = name + self.difficulty = difficulty + self.minutes = minutes + self.imageURL = imageURL + self.imageName = imageName + self.ingredients = ingredients + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/RecipeView.swift b/SWFrontUI/ShopwiseFrontEndUI/RecipeView.swift new file mode 100644 index 0000000..2b2485a --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/RecipeView.swift @@ -0,0 +1,183 @@ +import SwiftUI + +struct RecipeView: View { + @State private var query = "" + @State private var expandedID: String? = nil + @State private var excludedByRecipe: [String: Set] = [:] + + @EnvironmentObject private var cartStore: CartStore + + private let recipes: [Recipe] = [ + Recipe( + id: "recipe_spaghetti", + name: "Spaghetti", + difficulty: "Easy", + minutes: 25, + imageURL: nil, + imageName: "spaghetti", + ingredients: [ + Ingredient(id: "ing_spaghetti", name: "Spaghetti pasta", unit: "1 lb", price: 2.49, imageURL: nil, imageName: nil), + Ingredient(id: "ing_marinara", name: "Marinara sauce", unit: "24 oz", price: 3.99, imageURL: nil, imageName: nil), + Ingredient(id: "ing_garlic", name: "Garlic", unit: "3 ct", price: 1.29, imageURL: nil, imageName: nil), + Ingredient(id: "ing_onion", name: "Onion", unit: "1 ct", price: 0.99, imageURL: nil, imageName: nil), + Ingredient(id: "ing_olive_oil", name: "Olive oil", unit: "16.9 oz", price: 6.49, imageURL: nil, imageName: nil), + Ingredient(id: "ing_parmesan", name: "Parmesan", unit: "6 oz", price: 4.49, imageURL: nil, imageName: nil) + ] + ), + Recipe( + id: "recipe_orange_chicken", + name: "Orange Chicken", + difficulty: "Medium", + minutes: 35, + imageURL: nil, + imageName: "orangechicken", + ingredients: [ + Ingredient(id: "ing_chicken_breast", name: "Chicken breast", unit: "1 lb", price: 5.99, imageURL: nil, imageName: nil), + Ingredient(id: "ing_cornstarch", name: "Cornstarch", unit: "16 oz", price: 1.99, imageURL: nil, imageName: nil), + Ingredient(id: "ing_orange_juice", name: "Orange juice", unit: "12 oz", price: 3.49, imageURL: nil, imageName: nil), + Ingredient(id: "ing_soy_sauce", name: "Soy sauce", unit: "10 oz", price: 2.29, imageURL: nil, imageName: nil), + Ingredient(id: "ing_honey", name: "Honey", unit: "12 oz", price: 4.79, imageURL: nil, imageName: nil), + Ingredient(id: "ing_garlic", name: "Garlic", unit: "3 ct", price: 1.29, imageURL: nil, imageName: nil), + Ingredient(id: "ing_ginger", name: "Ginger", unit: "3 oz", price: 1.49, imageURL: nil, imageName: nil), + Ingredient(id: "ing_rice", name: "Rice", unit: "2 lb", price: 3.79, imageURL: nil, imageName: nil) + ] + ) + ] + + private var filtered: [Recipe] { + if query.isEmpty { return recipes } + return recipes.filter { $0.name.localizedCaseInsensitiveContains(query) } + } + + var body: some View { + List { + ForEach(filtered) { recipe in + Section { + Button { + withAnimation(.snappy) { + expandedID = (expandedID == recipe.id) ? nil : recipe.id + } + } label: { + CardContainer { + HStack(spacing: 12) { + if let imageName = recipe.imageName { + Image(imageName) + .resizable() + .scaledToFill() + .frame(width: 54, height: 54) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } else { + Circle() + .fill(Color(.systemGray5)) + .frame(width: 54, height: 54) + .overlay( + Image(systemName: "fork.knife") + .foregroundStyle(.secondary) + ) + } + + VStack(alignment: .leading, spacing: 4) { + Text(recipe.name) + .font(.headline) + Text("\(recipe.difficulty) • \(recipe.minutes) min") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: expandedID == recipe.id ? "chevron.up" : "chevron.down") + .foregroundStyle(.secondary) + } + } + } + .buttonStyle(.plain) + + if expandedID == recipe.id { + CardContainer { + if let imageName = recipe.imageName { + Image(imageName) + .resizable() + .scaledToFill() + .frame(height: 160) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + Text("Ingredients") + .font(.headline) + + ForEach(recipe.ingredients) { ingredient in + let isExcluded = excludedByRecipe[recipe.id, default: []].contains(ingredient.id) + + Button { + if isExcluded { + excludedByRecipe[recipe.id, default: []].remove(ingredient.id) + } else { + excludedByRecipe[recipe.id, default: []].insert(ingredient.id) + } + } label: { + HStack(spacing: 10) { + Image(systemName: isExcluded ? "square" : "checkmark.square.fill") + .imageScale(.large) + + VStack(alignment: .leading, spacing: 2) { + Text(ingredient.name) + .strikethrough(isExcluded) + Text(ingredient.unit) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(String(format: "$%.2f", ingredient.price)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + Button { + addIngredientsToCart(recipe) + } label: { + Label("Add Ingredients to Cart", systemImage: "cart.badge.plus") + .frame(maxWidth: .infinity) + .foregroundStyle(.white) + } + .buttonStyle(.borderedProminent) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } + } + .listStyle(.plain) + .navigationTitle("ShopWise") + .searchable(text: $query, prompt: "Search recipes…") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 14) { + NavigationLink { SettingsView() } label: { Image(systemName: "gearshape") } + NavigationLink { ProfileView() } label: { Image(systemName: "person.crop.circle") } + } + } + } + } + + private func addIngredientsToCart(_ recipe: Recipe) { + let excluded = excludedByRecipe[recipe.id, default: []] + + for ingredient in recipe.ingredients where !excluded.contains(ingredient.id) { + cartStore.add( + id: ingredient.id, + name: ingredient.name, + unit: ingredient.unit, + price: ingredient.price + ) + } + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/SearchView.swift b/SWFrontUI/ShopwiseFrontEndUI/SearchView.swift new file mode 100644 index 0000000..4529793 --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/SearchView.swift @@ -0,0 +1,306 @@ +import SwiftUI + +struct SearchView: View { + @EnvironmentObject private var auth: AuthManager + @EnvironmentObject private var cartStore: CartStore + + @State private var query = "" + @State private var selectedFilter: String = "All" + @State private var results: [WalmartItem] = [] + @State private var isLoading = false + @State private var errorText: String? = nil + + @State private var pageSize = 50 + @State private var offset = 0 + + @State private var isLoadingMore = false + @State private var hasMore = true + + private let filters = ["All", "Ingredients", "Non-Ingredients"] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + + // Title + Text("ShopWise") + .font(.system(size: 34, weight: .bold)) + .padding(.top, 6) + + // Search bar (custom like your right screenshot) + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + + TextField("Search products...", text: $query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + + if !query.isEmpty { + Button { + query = "" + searchItems(reset: true) + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.secondary) + .padding(10) + .background(.ultraThinMaterial) + .clipShape(Circle()) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 18)) + + // Category chips row + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(filters, id: \.self) { f in + SearchCategoryChip(title: f, isSelected: selectedFilter == f) { + selectedFilter = f + searchItems(reset: true) // ✅ ADD HERE + } + } + } + .padding(.vertical, 2) + } + + // Content + if isLoading { + HStack { + Spacer() + ProgressView("Loading…") + Spacer() + } + .padding(.top, 24) + } else if let errorText { + Text(errorText) + .foregroundStyle(.red) + .padding(.top, 10) + } else { + LazyVStack(spacing: 14) { + ForEach(Array(filteredResults.enumerated()), id: \.element.id) { index, item in + ProductCard( + imageURL: bestImageURL(for: item), + title: item.name, + unit: unitText(for: item), + priceText: formatPrice(item.retail_price), + onAdd: { + cartStore.add(id: String(item.id), name: item.name, unit: unitText(for: item) ?? "", price: item.retail_price ?? 0) + } + ) + .onAppear { + // When the LAST item appears, load more + if index == filteredResults.count - 1 { + searchItems(reset: false) + } + } + } } + .padding(.top, 4) + } + + Spacer(minLength: 20) + } + .padding(.horizontal, 16) + .padding(.bottom, 20) + } + .background(Color(.systemBackground)) + .navigationBarTitleDisplayMode(.inline) + .appToolbar() // <-- uses your existing Settings/Profile toolbar + .onChange(of: query) { _, _ in + searchItems(reset: true) + } + .task { + searchItems(reset: true) + } + } + + // MARK: - Filtering + private var filteredResults: [WalmartItem] { + switch selectedFilter { + case "Ingredients": + return results.filter { $0.ingredient == true } + case "Non-Ingredients": + return results.filter { $0.ingredient == false } + default: + return results + } + } + + // MARK: - Helpers (image/price/unit) + private func bestImageURL(for item: WalmartItem) -> URL? { + if let s = item.thumbnailImage, let url = URL(string: s), !s.isEmpty { return url } + if let s = item.mediumImage, let url = URL(string: s), !s.isEmpty { return url } + if let s = item.largeImage, let url = URL(string: s), !s.isEmpty { return url } + return nil + } + + private func formatPrice(_ p: Double?) -> String { + guard let p else { return "Price unavailable" } + return String(format: "$%.2f", p) + } + + private func unitText(for item: WalmartItem) -> String? { + // Your Supabase data doesn't include a unit column. + // If classifiers contains something unit-like, show it; otherwise hide. + let cls = (item.classifiers ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if cls.isEmpty { return nil } + return cls + } + + // MARK: - Supabase fetch + private func searchItems(reset: Bool = true) { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + + // If we're already loading more, don't double-fire + if isLoadingMore { return } + + if reset { + offset = 0 + results = [] + hasMore = true + } else { + // if we already know there are no more rows, stop + if !hasMore { return } + } + + if reset { + isLoading = true + } else { + isLoadingMore = true + } + errorText = nil + + Task { + do { + let ingredientOnly: Bool? = { + switch selectedFilter { + case "Ingredients": return true + case "Non-Ingredients": return false + default: return nil + } + }() + + let newItems = try await auth.fetchWalmartItems( + search: trimmed.isEmpty ? nil : trimmed, + ingredientOnly: ingredientOnly, // ✅ NEW + limit: pageSize, + offset: offset + ) + + await MainActor.run { + // Append results + results.append(contentsOf: newItems) + + // Move offset forward + offset += newItems.count + + // If we got fewer than pageSize, we reached the end + if newItems.count < pageSize { hasMore = false } + + isLoading = false + isLoadingMore = false + } + } catch { + await MainActor.run { + errorText = "Search failed: \(error.localizedDescription)" + isLoading = false + isLoadingMore = false + } + } + } + } +} + + +struct SearchCategoryChip: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(isSelected ? Color.blue.opacity(0.18) : Color(.systemGray6)) + .foregroundStyle(isSelected ? Color.blue : Color.primary) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } +} + +struct ProductCard: View { + let imageURL: URL? + let title: String + let unit: String? + let priceText: String + let onAdd: () -> Void + + var body: some View { + HStack(spacing: 14) { + AsyncImage(url: imageURL) { phase in + switch phase { + case .empty: + ProgressView() + .frame(width: 74, height: 74) + case .success(let image): + image + .resizable() + .scaledToFill() + .frame(width: 74, height: 74) + .clipped() + case .failure: + Image(systemName: "photo") + .frame(width: 74, height: 74) + .foregroundStyle(.secondary) + @unknown default: + EmptyView() + .frame(width: 74, height: 74) + } + } + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.headline) + .lineLimit(2) + + if let unit, !unit.isEmpty { + Text(unit) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Text(priceText) + .font(.headline) + } + + Spacer() + + Button(action: onAdd) { + HStack(spacing: 8) { + Image(systemName: "cart.badge.plus") + Text("Add") + } + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .foregroundStyle(.white) + .background(Color.blue) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + .padding(14) + .background(Color(.systemGray6).opacity(0.65)) + .clipShape(RoundedRectangle(cornerRadius: 18)) + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/SettingsView.swift b/SWFrontUI/ShopwiseFrontEndUI/SettingsView.swift new file mode 100644 index 0000000..ab559b8 --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/SettingsView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct SettingsView: View { + @AppStorage("notificationsEnabled") private var notificationsEnabled = true + @AppStorage("locationEnabled") private var locationEnabled = true + @AppStorage("darkModeEnabled") private var darkModeEnabled = false + + var body: some View { + List { + Section("Preferences") { + Toggle("Enable Notifications", isOn: $notificationsEnabled) + Toggle("Use Location Services", isOn: $locationEnabled) + Toggle("Dark Mode", isOn: $darkModeEnabled) + } + + Section("Support") { + Button("Help Center") { + // placeholder + } + + Button("About ShopWise") { + // placeholder + } + } + + Section("App Info") { + HStack { + Text("Version") + Spacer() + Text("1.0") + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Settings") + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/ShoppingListView.swift b/SWFrontUI/ShopwiseFrontEndUI/ShoppingListView.swift new file mode 100644 index 0000000..24421e7 --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/ShoppingListView.swift @@ -0,0 +1,123 @@ +import SwiftUI + +struct ShoppingListView: View { + @EnvironmentObject private var cartStore: CartStore + @Environment(\.dismiss) private var dismiss + + @State private var checkedIDs: Set = [] + + private var checkedTotal: Double { + cartStore.items + .filter { checkedIDs.contains($0.id) } + .reduce(0) { $0 + ($1.price * Double($1.quantity)) } + } + + private var checkedCount: Int { + cartStore.items.filter { checkedIDs.contains($0.id) }.count + } + + var body: some View { + List { + Section("Shopping List") { + if cartStore.items.isEmpty { + Text("No items to shop for.") + .foregroundStyle(.secondary) + } else { + ForEach(cartStore.items) { item in + Button { + toggle(item.id) + } label: { + HStack(spacing: 12) { + Image(systemName: checkedIDs.contains(item.id) ? "checkmark.circle.fill" : "circle") + .imageScale(.large) + + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + .strikethrough(checkedIDs.contains(item.id)) + Text("\(item.quantity) × \(item.unit)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(String(format: "$%.2f", item.price * Double(item.quantity))) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + .buttonStyle(.plain) + } + } + } + + if !cartStore.items.isEmpty { + Section { + Button("Mark All") { + checkedIDs = Set(cartStore.items.map { $0.id }) + } + + Button("Clear Checks") { + checkedIDs.removeAll() + } + + Button(role: .destructive) { + cartStore.clear() + checkedIDs.removeAll() + dismiss() + } label: { + Text("Clear Cart") + } + } + } + } + .navigationTitle("Shopping List") + .appToolbar() + .safeAreaInset(edge: .bottom) { + if !cartStore.items.isEmpty { + VStack(spacing: 0) { + Divider() + + VStack(spacing: 10) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Checked: \(checkedCount)/\(cartStore.items.count)") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("Current Total") + .font(.headline) + } + + Spacer() + + Text(String(format: "$%.2f", checkedTotal)) + .font(.title3) + .monospacedDigit() + } + + Button { + cartStore.clear() + checkedIDs.removeAll() + dismiss() + } label: { + Label("Finish Trip", systemImage: "checkmark.seal.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.ultraThinMaterial) + } + } + } + } + + private func toggle(_ id: String) { + if checkedIDs.contains(id) { + checkedIDs.remove(id) + } else { + checkedIDs.insert(id) + } + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/StoreLocation.swift b/SWFrontUI/ShopwiseFrontEndUI/StoreLocation.swift index 11ae318..9232908 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/StoreLocation.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/StoreLocation.swift @@ -1,8 +1,31 @@ +import Foundation import CoreLocation -//struct StoreLocation: Identifiable, Hashable { - //let id: UUID - //let name: String - //let coordinate: CLLocationCoordinate2D - //} - +struct StoreLocation: Identifiable, Codable, Hashable { + let id: String + let name: String + let latitude: Double + let longitude: Double + let address: String? + let chain: String? + + init( + id: String, + name: String, + latitude: Double, + longitude: Double, + address: String? = nil, + chain: String? = nil + ) { + self.id = id + self.name = name + self.latitude = latitude + self.longitude = longitude + self.address = address + self.chain = chain + } + + var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/UserPreferences.swift b/SWFrontUI/ShopwiseFrontEndUI/UserPreferences.swift new file mode 100644 index 0000000..bf1cf62 --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/UserPreferences.swift @@ -0,0 +1,11 @@ +import Foundation + +struct UserPreferences: Codable, Hashable { + var dietPreferences: [String] + var allergies: [String] + + static let empty = UserPreferences( + dietPreferences: [], + allergies: [] + ) +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/WalmartItems.swift b/SWFrontUI/ShopwiseFrontEndUI/WalmartItems.swift index 6b0fffe..44b2e4c 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/WalmartItems.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/WalmartItems.swift @@ -18,4 +18,3 @@ struct WalmartItem: Identifiable, Decodable { let largeImage: String? let color: String? } -