diff --git a/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.xcworkspace/xcuserdata/jchang.xcuserdatad/UserInterfaceState.xcuserstate b/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.xcworkspace/xcuserdata/jchang.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..4f911f0 Binary files /dev/null and b/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.xcworkspace/xcuserdata/jchang.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.xcworkspace/xcuserdata/nicholascastellanos.xcuserdatad/UserInterfaceState.xcuserstate b/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.xcworkspace/xcuserdata/nicholascastellanos.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index d183f9d..0000000 Binary files a/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.xcworkspace/xcuserdata/nicholascastellanos.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/SWFrontUI/ShopwiseFrontEndUI/AppToolbar.swift b/SWFrontUI/ShopwiseFrontEndUI/AppToolbar.swift index 11b6fd2..e8eeb80 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/AppToolbar.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/AppToolbar.swift @@ -1,17 +1,24 @@ import SwiftUI struct AppToolbar: ViewModifier { + @State private var showAccount = false + func body(content: Content) -> some View { content .toolbar { ToolbarItem(placement: .topBarTrailing) { - NavigationLink { - ProfileView() + Button { + showAccount = true } label: { Image(systemName: "person.crop.circle") } } } + .sheet(isPresented: $showAccount) { + NavigationStack { + ProfileView() + } + } } } diff --git a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/Contents.json b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/Contents.json index 74d6a72..73c0059 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/Contents.json +++ b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/Contents.json @@ -1,6 +1,6 @@ { - "info": { - "author": "xcode", - "version": 1 + "info" : { + "author" : "xcode", + "version" : 1 } } diff --git a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_99ranch.imageset/Contents.json b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_99ranch.imageset/Contents.json new file mode 100644 index 0000000..c47f98f --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_99ranch.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logo_99ranch.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_99ranch.imageset/logo_99ranch.png b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_99ranch.imageset/logo_99ranch.png new file mode 100644 index 0000000..31fbefe Binary files /dev/null and b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_99ranch.imageset/logo_99ranch.png differ diff --git a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_aldi.imageset/Contents.json b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_aldi.imageset/Contents.json new file mode 100644 index 0000000..a24d05f --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_aldi.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logo_aldi.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_aldi.imageset/logo_aldi.png b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_aldi.imageset/logo_aldi.png new file mode 100644 index 0000000..0121569 Binary files /dev/null and b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_aldi.imageset/logo_aldi.png differ diff --git a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_food4less.imageset/Contents.json b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_food4less.imageset/Contents.json new file mode 100644 index 0000000..814771f --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_food4less.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logo_food4less.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_food4less.imageset/logo_food4less.png b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_food4less.imageset/logo_food4less.png new file mode 100644 index 0000000..5ee8b59 Binary files /dev/null and b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_food4less.imageset/logo_food4less.png differ diff --git a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_ralphs.imageset/Contents.json b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_ralphs.imageset/Contents.json new file mode 100644 index 0000000..d3f72c0 --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_ralphs.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logo_ralphs.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_ralphs.imageset/logo_ralphs.png b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_ralphs.imageset/logo_ralphs.png new file mode 100644 index 0000000..84bfedf Binary files /dev/null and b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_ralphs.imageset/logo_ralphs.png differ diff --git a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_smartfinal.imageset/Contents.json b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_smartfinal.imageset/Contents.json new file mode 100644 index 0000000..f22f6fc --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_smartfinal.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logo_smartfinal.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_smartfinal.imageset/logo_smartfinal.png b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_smartfinal.imageset/logo_smartfinal.png new file mode 100644 index 0000000..3163cbd Binary files /dev/null and b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_smartfinal.imageset/logo_smartfinal.png differ diff --git a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_sprouts.imageset/Contents.json b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_sprouts.imageset/Contents.json new file mode 100644 index 0000000..7ac3ab7 --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_sprouts.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logo_sprouts.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_sprouts.imageset/logo_sprouts.png b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_sprouts.imageset/logo_sprouts.png new file mode 100644 index 0000000..f3a0a98 Binary files /dev/null and b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_sprouts.imageset/logo_sprouts.png differ diff --git a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_stater.imageset/Contents.json b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_stater.imageset/Contents.json new file mode 100644 index 0000000..376be5c --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_stater.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logo_stater.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_stater.imageset/logo_stater.png b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_stater.imageset/logo_stater.png new file mode 100644 index 0000000..ff5f5e7 Binary files /dev/null and b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_stater.imageset/logo_stater.png differ diff --git a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_walmart.imageset/Contents.json b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_walmart.imageset/Contents.json new file mode 100644 index 0000000..fa9a6a6 --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_walmart.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logo_walmart.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_walmart.imageset/logo_walmart.png b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_walmart.imageset/logo_walmart.png new file mode 100644 index 0000000..22f5f71 Binary files /dev/null and b/SWFrontUI/ShopwiseFrontEndUI/Assets.xcassets/logo_walmart.imageset/logo_walmart.png differ diff --git a/SWFrontUI/ShopwiseFrontEndUI/AuthManager.swift b/SWFrontUI/ShopwiseFrontEndUI/AuthManager.swift index 0c96f61..257c59a 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/AuthManager.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/AuthManager.swift @@ -1,20 +1,21 @@ import Foundation +import CoreLocation import SwiftUI import Combine - + struct PostgrestError: Decodable { let message: String? let details: String? let hint: String? let code: String? } - + @MainActor final class AuthManager: ObservableObject { // MARK: - Supabase config private let supabaseURL = URL(string: "https://vpmxdkrwqxgullnducey.supabase.co")! private let anonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZwbXhka3J3cXhndWxsbmR1Y2V5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzE0NDQ5ODMsImV4cCI6MjA4NzAyMDk4M30.NYievlganIUF4tVQvgK8NAaMAk2_y6NHnijvbuiWKCw" - + // MARK: - Published state @Published var isLoading: Bool = false @Published var errorMessage: String? = nil @@ -22,27 +23,27 @@ final class AuthManager: ObservableObject { @Published var userEmail: String? = nil @Published var userID: String? = nil @Published var userName: String? = nil - + // Tokens private(set) var accessToken: String? = nil private(set) var refreshToken: String? = nil - + // Simple token persistence (fast). Use Keychain later if you want. private let accessTokenKey = "sb_access_token" private let refreshTokenKey = "sb_refresh_token" - + init() { loadTokensFromStorage() Task { await restoreSession() } } - + // MARK: - Public API private struct SignUpBody: Codable { let email: String let password: String let data: Meta - + struct Meta: Codable { let name: String } @@ -56,9 +57,9 @@ final class AuthManager: ObservableObject { )! comps.queryItems = [URLQueryItem(name: "grant_type", value: "password")] let url = comps.url! - + let body: [String: String] = ["email": email, "password": password] - + let (data, _) = try await self.request( url: url, method: "POST", @@ -75,7 +76,7 @@ final class AuthManager: ObservableObject { enum AuthError: LocalizedError { case emailConfirmationRequired - + var errorDescription: String? { switch self { case .emailConfirmationRequired: @@ -83,7 +84,7 @@ final class AuthManager: ObservableObject { } } } - + func signUp(name: String, email: String, password: String) async throws { try await runAuthCall { let url = self.supabaseURL.appendingPathComponent("auth/v1/signup") @@ -92,14 +93,14 @@ final class AuthManager: ObservableObject { password: password, data: .init(name: name) ) - + let (data, _) = try await self.request( url: url, method: "POST", jsonBody: body, bearerToken: nil ) - + if let session = try? JSONDecoder().decode(SupabaseSession.self, from: data), !session.access_token.isEmpty { self.applySession(session) @@ -111,7 +112,7 @@ final class AuthManager: ObservableObject { } } } - + func signOut() async { do { try await runAuthCall { @@ -119,16 +120,16 @@ final class AuthManager: ObservableObject { self.clearSession() return } - + let url = self.supabaseURL.appendingPathComponent("auth/v1/logout") - + _ = try await self.request( url: url, method: "POST", jsonBody: nil as [String:String]?, bearerToken: token ) - + self.clearSession() } } catch { @@ -136,14 +137,14 @@ final class AuthManager: ObservableObject { self.clearSession() } } - + /// Called on app launch: validate token and fetch /auth/v1/user func restoreSession() async { guard let token = accessToken else { isSignedIn = false return } - + do { let url = supabaseURL.appendingPathComponent("auth/v1/user") let (data, _) = try await request( @@ -169,37 +170,37 @@ final class AuthManager: ObservableObject { } } } - + // MARK: - Refresh - + private func refreshSession() async throws -> SupabaseSession { guard let rToken = refreshToken else { throw URLError(.userAuthenticationRequired) } - + var comps = URLComponents(url: supabaseURL.appendingPathComponent("auth/v1/token"), resolvingAgainstBaseURL: false)! comps.queryItems = [URLQueryItem(name: "grant_type", value: "refresh_token")] let url = comps.url! - + let body = ["refresh_token": rToken] - + let (data, _) = try await request( url: url, method: "POST", jsonBody: body, bearerToken: nil ) - + let session = try JSONDecoder().decode(SupabaseSession.self, from: data) applySession(session) return session } - + // MARK: - Helpers - + private func runAuthCall(_ work: () async throws -> T) async throws -> T { self.errorMessage = nil self.isLoading = true defer { self.isLoading = false } - + do { return try await work() } catch { @@ -207,7 +208,7 @@ final class AuthManager: ObservableObject { throw error } } - + private func applySession(_ session: SupabaseSession) { self.accessToken = session.access_token self.refreshToken = session.refresh_token @@ -216,7 +217,7 @@ final class AuthManager: ObservableObject { saveTokensToStorage() self.isSignedIn = true } - + private func clearSession() { self.accessToken = nil self.refreshToken = nil @@ -226,7 +227,7 @@ final class AuthManager: ObservableObject { self.isSignedIn = false deleteTokensFromStorage() } - + private func request( url: URL, method: String, @@ -234,16 +235,16 @@ final class AuthManager: ObservableObject { bearerToken: String?, extraHeaders: [String: String] = [:] ) async throws -> (Data, HTTPURLResponse) { - + var req = URLRequest(url: url) req.httpMethod = method req.setValue(anonKey, forHTTPHeaderField: "apikey") req.setValue("application/json", forHTTPHeaderField: "Content-Type") - + if let bearerToken { req.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") } - + for (key, value) in extraHeaders { req.setValue(value, forHTTPHeaderField: key) } @@ -251,29 +252,29 @@ final class AuthManager: ObservableObject { if let jsonBody { req.httpBody = try JSONEncoder().encode(jsonBody) } - + let (data, resp) = try await URLSession.shared.data(for: req) guard let http = resp as? HTTPURLResponse else { throw URLError(.badServerResponse) } - + if !(200...299).contains(http.statusCode) { - - // ✅ 1) Print raw body in Xcode console + + // 1) Print raw body in Xcode console Debugging let raw = String(data: data, encoding: .utf8) ?? "" print("❌ Supabase HTTP \(http.statusCode) \(url.absoluteString)") print("❌ Body: \(raw)") - - // ✅ 2) Try PostgREST error first + + // 2) Try PostgREST error first if let pg = try? JSONDecoder().decode(PostgrestError.self, from: data), let msg = pg.message, !msg.isEmpty { var full = msg if let details = pg.details, !details.isEmpty { full += "\nDetails: \(details)" } if let hint = pg.hint, !hint.isEmpty { full += "\nHint: \(hint)" } - + throw NSError(domain: "Supabase", code: http.statusCode, userInfo: [NSLocalizedDescriptionKey: full]) } - - // ✅ 3) Then try auth-style error + + // 3) Then try auth-style error if let sb = try? JSONDecoder().decode(SupabaseError.self, from: data) { let msg = sb.msg ?? sb.error_description ?? sb.error ?? "" if !msg.isEmpty { @@ -281,37 +282,37 @@ final class AuthManager: ObservableObject { userInfo: [NSLocalizedDescriptionKey: msg]) } } - - // ✅ 4) Last resort: show raw response + + // 4) Last resort: show raw response throw NSError(domain: "Supabase", code: http.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP \(http.statusCode)\n\(raw)"]) } - + return (data, http) } - + private func prettyError(_ error: Error) -> String { (error as NSError).localizedDescription } - + private func saveTokensToStorage() { UserDefaults.standard.set(accessToken, forKey: accessTokenKey) UserDefaults.standard.set(refreshToken, forKey: refreshTokenKey) } - + private func loadTokensFromStorage() { accessToken = UserDefaults.standard.string(forKey: accessTokenKey) refreshToken = UserDefaults.standard.string(forKey: refreshTokenKey) } - + private func deleteTokensFromStorage() { UserDefaults.standard.removeObject(forKey: accessTokenKey) UserDefaults.standard.removeObject(forKey: refreshTokenKey) } } - + // MARK: - Models (Supabase Auth JSON) - + struct SupabaseSession: Codable { let access_token: String let refresh_token: String @@ -319,145 +320,203 @@ struct SupabaseSession: Codable { let expires_in: Int? let user: SupabaseUser? } - + struct SupabaseUser: Codable { let id: String? let email: String? let user_metadata: UserMetadata? } - + struct UserMetadata: Codable { let name: String? } - + struct SupabaseError: Codable { let error: String? let error_description: String? let msg: String? } - - -// ------ For Walmart Items ------ - + + +// MARK: - Kroger Items + +struct KrogerItem: Codable, Identifiable { + let productId: Int + let name: String + let brand: String? + let price: String? // "1.49;1.49;2.49" — index-aligned with store_ids + let classifier: String? + let categories: String? + let image_url: String? + let size: String? + let search_keyword: String? + let store_ids: String? + + var id: Int { productId } + + var priceList: [Double] { + guard let price else { return [] } + return price.split(separator: ";") + .compactMap { Double($0.trimmingCharacters(in: .whitespaces)) } + } + + var storeIdList: [String] { + guard let store_ids else { return [] } + return store_ids.split(separator: ";") + .map { $0.trimmingCharacters(in: .whitespaces) } + } + + var minPrice: Double? { + priceList.filter { $0 > 0 }.min() + } + + func price(forStoreId storeId: String) -> Double? { + guard let index = storeIdList.firstIndex(of: storeId), + index < priceList.count else { return nil } + return priceList[index] + } + + func displayPrice(forStoreId storeId: String? = nil) -> String { + let p: Double? + if let storeId { + p = price(forStoreId: storeId) ?? minPrice + } else { + p = minPrice + } + guard let p else { return "Price N/A" } + return String(format: "$%.2f", p) + } + + var imageURL: URL? { + guard let img = image_url, !img.isEmpty else { return nil } + return URL(string: img) + } +} + extension AuthManager { - func fetchWalmartItems( + func fetchKrogerItems( search: String? = nil, - ingredientOnly: Bool? = nil, + classifier: String? = nil, limit: Int = 50, offset: Int = 0 - ) async throws -> [WalmartItem] { - - let tableName = "classified_ingredients_aa" // <-- CHANGE THIS - + ) async throws -> [KrogerItem] { let base = supabaseURL .appendingPathComponent("rest/v1") - .appendingPathComponent(tableName) - + .appendingPathComponent("kroger_ingredients2") + var comps = URLComponents(url: base, resolvingAgainstBaseURL: false)! var q: [URLQueryItem] = [ URLQueryItem( name: "select", - value: "id,name,ingredient,classifiers,retail_price,thumbnailImage,mediumImage,largeImage,color" + value: "productId,name,brand,price,classifier,categories,image_url,size,search_keyword,store_ids" ), - URLQueryItem(name: "limit", value: "\(limit)"), + URLQueryItem(name: "limit", value: "\(limit)"), URLQueryItem(name: "offset", value: "\(offset)"), - URLQueryItem(name: "order", value: "id.asc") + URLQueryItem(name: "order", value: "productId.asc"), ] - - if let s = search?.trimmingCharacters(in: .whitespacesAndNewlines), - !s.isEmpty { - q.append(URLQueryItem(name: "name", value: "ilike.*\(s)*")) + + if let s = search?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty { + q.append(URLQueryItem( + name: "or", + value: "(name.ilike.*\(s)*,search_keyword.ilike.*\(s)*)" + )) } - - if let ingredientOnly { - // PostgREST boolean filter - q.append(URLQueryItem(name: "ingredient", value: "eq.\(ingredientOnly ? "true" : "false")")) + + if let cls = classifier, !cls.isEmpty { + q.append(URLQueryItem(name: "classifier", value: "eq.\(cls)")) } - + comps.queryItems = q guard let url = comps.url else { throw URLError(.badURL) } - + let (data, _) = try await request( url: url, method: "GET", jsonBody: Optional.none, bearerToken: accessToken ) - - return try JSONDecoder().decode([WalmartItem].self, from: data) + + do { + return try JSONDecoder().decode([KrogerItem].self, from: data) + } catch { + let raw = String(data: data, encoding: .utf8) ?? "" + print("❌ KrogerItem decode error: \(error)") + print("❌ Raw JSON: \(raw.prefix(500))") + throw error + } } } - + //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", @@ -469,14 +528,14 @@ extension AuthManager { ) } } - + // --- Recpie Fetcher --- // - + extension AuthManager { func fetchRecipes(search: String? = nil, limit: Int = 50, offset: Int = 0) async throws -> [RecipeRow] { let base = supabaseURL .appendingPathComponent("rest/v1/Recipes_Kaggle") - + var comps = URLComponents(url: base, resolvingAgainstBaseURL: false)! var queryItems: [URLQueryItem] = [ URLQueryItem( @@ -487,20 +546,399 @@ extension AuthManager { URLQueryItem(name: "offset", value: "\(offset)"), URLQueryItem(name: "order", value: "id.asc") ] - + if let s = search?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty { queryItems.append(URLQueryItem(name: "Title", value: "ilike.*\(s)*")) } - + comps.queryItems = queryItems - + let (data, _) = try await request( url: comps.url!, method: "GET", jsonBody: Optional.none, bearerToken: accessToken ) - + return try JSONDecoder().decode([RecipeRow].self, from: data) } } + + +// MARK: - Recipe Matches + +struct RecipeMatch: Codable, Identifiable { + let id: Int + let recipe_id: Int + let raw_ingredient: String + let matched_name: String? + let matched_product_id: String? + let matched_image: String? + let matched_size: String? + let min_price: Double? + let price_raw: String? + let store_ids: String? + let score: Double? + let confidence: String? + let match_rank: Int? + + var displayPrice: String { + guard let p = min_price else { return "Price N/A" } + return String(format: "$%.2f", p) + } + + var imageURL: URL? { + guard let img = matched_image, !img.isEmpty else { return nil } + return URL(string: img) + } +} + +extension AuthManager { + func fetchRecipeMatches(recipeId: Int) async throws -> [RecipeMatch] { + let base = supabaseURL + .appendingPathComponent("rest/v1") + .appendingPathComponent("recipe_matches") + + var comps = URLComponents(url: base, resolvingAgainstBaseURL: false)! + comps.queryItems = [ + URLQueryItem(name: "select", value: "id,recipe_id,raw_ingredient,matched_name,matched_product_id,matched_image,matched_size,min_price,price_raw,store_ids,score,confidence,match_rank"), + URLQueryItem(name: "recipe_id", value: "eq.\(recipeId)"), + URLQueryItem(name: "match_rank", value: "not.is.null"), + URLQueryItem(name: "order", value: "raw_ingredient.asc,match_rank.asc"), + ] + + guard let url = comps.url else { throw URLError(.badURL) } + + let (data, _) = try await request( + url: url, method: "GET", + jsonBody: Optional.none, + bearerToken: accessToken + ) + + do { + return try JSONDecoder().decode([RecipeMatch].self, from: data) + } catch { + let raw = String(data: data, encoding: .utf8) ?? "" + print("❌ RecipeMatch decode error: \(error)") + print("❌ Raw: \(raw.prefix(300))") + throw error + } + } +} + +// MARK: - Kroger Store Locations + +struct KrogerStore: Codable, Identifiable { + let locationId: Int + let name: String? + let chain: String? + let address_line1: String? + let address_city: String? + let address_state: String? + let address_zipCode: Int? + let geo_latitude: Double? + let geo_longitude: Double? + + var id: Int { locationId } + + var displayName: String { name ?? "Kroger Store" } + + var displayAddress: String { + [address_line1, address_city, address_state] + .compactMap { $0 }.filter { !$0.isEmpty } + .joined(separator: ", ") + } + + var coordinate: CLLocationCoordinate2D? { + guard let lat = geo_latitude, let lng = geo_longitude else { return nil } + return CLLocationCoordinate2D(latitude: lat, longitude: lng) + } + + func distanceMiles(from coord: CLLocationCoordinate2D) -> Double? { + guard let c = coordinate else { return nil } + let loc1 = CLLocation(latitude: c.latitude, longitude: c.longitude) + let loc2 = CLLocation(latitude: coord.latitude, longitude: coord.longitude) + return loc1.distance(from: loc2) / 1609.34 + } +} + +extension AuthManager { + func fetchNearbyKrogerStores( + near coordinate: CLLocationCoordinate2D, + radiusDegrees: Double = 0.2 + ) async throws -> [KrogerStore] { + let base = supabaseURL + .appendingPathComponent("rest/v1") + .appendingPathComponent("kroger_locations") + + var comps = URLComponents(url: base, resolvingAgainstBaseURL: false)! + comps.queryItems = [ + URLQueryItem(name: "select", value: "locationId,name,chain,address_line1,address_city,address_state,address_zipCode,geo_latitude,geo_longitude"), + URLQueryItem(name: "geo_latitude", value: "gte.\(coordinate.latitude - radiusDegrees)"), + URLQueryItem(name: "geo_latitude", value: "lte.\(coordinate.latitude + radiusDegrees)"), + URLQueryItem(name: "geo_longitude", value: "gte.\(coordinate.longitude - radiusDegrees)"), + URLQueryItem(name: "geo_longitude", value: "lte.\(coordinate.longitude + radiusDegrees)"), + URLQueryItem(name: "limit", value: "50"), + ] + + guard let url = comps.url else { throw URLError(.badURL) } + + let (data, _) = try await request( + url: url, method: "GET", + jsonBody: Optional.none, + bearerToken: accessToken + ) + + let stores = try JSONDecoder().decode([KrogerStore].self, from: data) + return stores + .filter { $0.coordinate != nil } + .sorted { (a: KrogerStore, b: KrogerStore) -> Bool in + (a.distanceMiles(from: coordinate) ?? 999) < + (b.distanceMiles(from: coordinate) ?? 999) + } + } +} + +// MARK: - Scraped Ingredients (multi-store price comparison) + +struct ScrapedIngredient: Codable, Identifiable { + let id: String + let taxonomy: String + let store: String + let name: String + let price: Double? + let price_raw: String? + let price_unit: String? + let quantity: String? + let image_url: String? + let description: String? + let out_of_stock: Bool? + + var displayPrice: String { + guard let p = price else { return "Price N/A" } + return String(format: "$%.2f", p) + } + + var imageURL: URL? { + guard let img = image_url, !img.isEmpty else { return nil } + return URL(string: img) + } + + var isAvailable: Bool { !(out_of_stock ?? false) } +} + +extension AuthManager { + + /// All store prices for a single taxonomy term, sorted cheapest first. + /// e.g. taxonomy = "Butter" → Walmart $2.97, Food4Less $3.29, Ralphs $3.49 ... + func fetchPricesForIngredient(taxonomy: String) async throws -> [ScrapedIngredient] { + let base = supabaseURL + .appendingPathComponent("rest/v1") + .appendingPathComponent("scraped_ingredients") + + var comps = URLComponents(url: base, resolvingAgainstBaseURL: false)! + comps.queryItems = [ + URLQueryItem(name: "select", value: "id,taxonomy,store,name,price,price_raw,price_unit,quantity,image_url,out_of_stock"), + URLQueryItem(name: "taxonomy", value: "eq.\(taxonomy)"), + URLQueryItem(name: "order", value: "price.asc.nullslast"), + URLQueryItem(name: "limit", value: "50"), + ] + + guard let url = comps.url else { throw URLError(.badURL) } + + let (data, _) = try await request( + url: url, method: "GET", + jsonBody: Optional.none, + bearerToken: accessToken + ) + return try JSONDecoder().decode([ScrapedIngredient].self, from: data) + } + + /// Search scraped ingredients by name or taxonomy across all stores. + /// Pass store: to filter to a single store. + func searchScrapedIngredients( + query: String, + store: String? = nil, + limit: Int = 50, + offset: Int = 0 + ) async throws -> [ScrapedIngredient] { + let base = supabaseURL + .appendingPathComponent("rest/v1") + .appendingPathComponent("scraped_ingredients") + + var comps = URLComponents(url: base, resolvingAgainstBaseURL: false)! + var q: [URLQueryItem] = [ + URLQueryItem(name: "select", value: "id,taxonomy,store,name,price,price_raw,price_unit,quantity,image_url,out_of_stock"), + URLQueryItem(name: "order", value: "price.asc.nullslast"), + URLQueryItem(name: "limit", value: "\(limit)"), + URLQueryItem(name: "offset", value: "\(offset)"), + ] + + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + q.append(URLQueryItem(name: "or", + value: "(name.ilike.*\(trimmed)*,taxonomy.ilike.*\(trimmed)*)")) + } + + if let store { + q.append(URLQueryItem(name: "store", value: "eq.\(store)")) + } + + comps.queryItems = q + guard let url = comps.url else { throw URLError(.badURL) } + + let (data, _) = try await request( + url: url, method: "GET", + jsonBody: Optional.none, + bearerToken: accessToken + ) + + do { + return try JSONDecoder().decode([ScrapedIngredient].self, from: data) + } catch { + let raw = String(data: data, encoding: .utf8) ?? "" + print("❌ ScrapedIngredient decode error: \(error)") + print("❌ Raw: \(raw.prefix(300))") + throw error + } + } +} + +// MARK: - Account Management + +extension AuthManager { + + /// Change the current user's password. + func changePassword(to newPassword: String) async throws { + guard let token = accessToken else { + throw URLError(.userAuthenticationRequired) + } + let url = supabaseURL.appendingPathComponent("auth/v1/user") + let (data, _) = try await request( + url: url, + method: "PUT", + jsonBody: ["password": newPassword], + bearerToken: token + ) + if let session = try? JSONDecoder().decode(SupabaseSession.self, from: data) { + applySession(session) + } + } + + /// Permanently delete the current user's account. + /// Calls the delete_current_user() Postgres RPC (SECURITY DEFINER) which + /// deletes the caller from auth.users without needing a service role key. + /// + /// One-time SQL setup in Supabase SQL Editor: + /// + /// CREATE OR REPLACE FUNCTION public.delete_current_user() + /// RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' + /// AS $$ BEGIN DELETE FROM auth.users WHERE id = auth.uid(); END; $$; + /// + func deleteAccount() async throws { + guard let uid = userID, + let token = accessToken else { + throw URLError(.userAuthenticationRequired) + } + + // Step 1: delete user data from public tables (user_preferences etc.) + try? await deleteUserData(uid: uid, token: token) + + // Step 2: call the SECURITY DEFINER RPC to delete the auth.users row. + // The function runs as postgres (bypasses RLS) so no service role key needed. + let url = supabaseURL.appendingPathComponent("rest/v1/rpc/delete_current_user") + + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue(anonKey, forHTTPHeaderField: "apikey") + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = Data("{}".utf8) // RPC requires a JSON body even when there are no params + + let (data, resp) = try await URLSession.shared.data(for: req) + guard let http = resp as? HTTPURLResponse else { throw URLError(.badServerResponse) } + + if !(200...299).contains(http.statusCode) { + let raw = String(data: data, encoding: .utf8) ?? "(empty response)" + throw NSError(domain: "Supabase", code: http.statusCode, + userInfo: [NSLocalizedDescriptionKey: "Delete failed (HTTP \(http.statusCode)): \(raw)"]) + } + + clearSession() + } + + private func deleteUserData(uid: String, token: String) async throws { + let base = supabaseURL + .appendingPathComponent("rest/v1/user_preferences") + var comps = URLComponents(url: base, resolvingAgainstBaseURL: false)! + comps.queryItems = [URLQueryItem(name: "user_id", value: "eq.\(uid)")] + guard let url = comps.url else { return } + _ = try await request( + url: url, + method: "DELETE", + jsonBody: nil as [String: String]?, + bearerToken: token + ) + } +} + +// MARK: - Scraped Recipe Matches (multi-store) + +struct ScrapedRecipeMatch: Codable, Identifiable { + let id: Int + let recipe_id: Int + let recipe_title: String? + let raw_ingredient: String + let matched_name: String? + let matched_product_id: String? + let matched_store: String? + let matched_image: String? + let matched_size: String? + let min_price: Double? + let score: Double? + let confidence: String? + let match_rank: Int? + + var displayPrice: String { + guard let p = min_price else { return "Price N/A" } + return String(format: "$%.2f", p) + } + + var imageURL: URL? { + guard let img = matched_image, !img.isEmpty else { return nil } + return URL(string: img) + } +} + +extension AuthManager { + func fetchScrapedRecipeMatches(recipeId: Int) async throws -> [ScrapedRecipeMatch] { + let base = supabaseURL + .appendingPathComponent("rest/v1") + .appendingPathComponent("scraped_recipe_matches") + + var comps = URLComponents(url: base, resolvingAgainstBaseURL: false)! + comps.queryItems = [ + URLQueryItem(name: "select", value: "id,recipe_id,recipe_title,raw_ingredient,matched_name,matched_product_id,matched_store,matched_image,matched_size,min_price,score,confidence,match_rank"), + URLQueryItem(name: "recipe_id", value: "eq.\(recipeId)"), + URLQueryItem(name: "match_rank", value: "not.is.null"), + URLQueryItem(name: "order", value: "raw_ingredient.asc,match_rank.asc"), + ] + + guard let url = comps.url else { throw URLError(.badURL) } + + let (data, _) = try await request( + url: url, method: "GET", + jsonBody: Optional.none, + bearerToken: accessToken + ) + + do { + return try JSONDecoder().decode([ScrapedRecipeMatch].self, from: data) + } catch { + let raw = String(data: data, encoding: .utf8) ?? "" + print("❌ ScrapedRecipeMatch decode error: \(error)") + print("❌ Raw: \(raw.prefix(300))") + throw error + } + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/MapView.swift b/SWFrontUI/ShopwiseFrontEndUI/MapView.swift index d438a9a..7e5e6ea 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/MapView.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/MapView.swift @@ -1,133 +1,575 @@ import SwiftUI +import UIKit import MapKit import CoreLocation +// MARK: - Store model + +struct StoreAnnotation: Identifiable { + let id: String + let name: String + let chain: String + let address: String + let coordinate: CLLocationCoordinate2D + let locationId: String? + + /// Name of the image asset in Assets.xcassets + var logoAsset: String { + switch chain { + case "Walmart": return "logo_walmart" + case "Ralphs": return "logo_ralphs" + case "Food 4 Less": return "logo_food4less" + case "Stater Bros.": return "logo_stater" + case "Sprouts": return "logo_sprouts" + case "ALDI": return "logo_aldi" + case "Smart & Final": return "logo_smartfinal" + case "99 Ranch": return "logo_99ranch" + default: return "" + } + } + + var markerTint: Color { + switch chain { + case "Walmart": return .blue + case "Ralphs", "Food 4 Less": return .orange + case "Stater Bros.": return .red + case "Sprouts": return .green + case "ALDI": return .purple + case "Smart & Final": return .cyan + case "99 Ranch": return Color(red: 0.9, green: 0.3, blue: 0.1) + default: return .gray + } + } + + var icon: String { + switch chain { + case "Walmart": return "cart.fill" + case "Sprouts": return "leaf.fill" + case "99 Ranch": return "globe.asia.australia.fill" + case "ALDI": return "dollarsign.circle.fill" + case "Smart & Final": return "bag.fill" + default: return "storefront.fill" + } + } +} + +// MARK: - Hardcoded stores near UCR (Kroger-family + Walmart) +// Kroger stores are hardcoded since kroger_locations table needs to be populated first. +// Ralphs and Food 4 Less are both Kroger-owned brands in SoCal. + +// MARK: - Ralphs / Food 4 Less (Kroger family) +private let krogerStoresNearUCR: [StoreAnnotation] = [ + StoreAnnotation(id: "ralphs_university", name: "Ralphs", chain: "Ralphs", + address: "1520 University Ave, Riverside, CA 92507", + coordinate: CLLocationCoordinate2D(latitude: 33.9738, longitude: -117.3456), locationId: nil), + StoreAnnotation(id: "food4less_university", name: "Food 4 Less", chain: "Food 4 Less", + address: "1275 University Ave, Riverside, CA 92507", + coordinate: CLLocationCoordinate2D(latitude: 33.9718, longitude: -117.3462), locationId: nil), + StoreAnnotation(id: "ralphs_magnolia", name: "Ralphs", chain: "Ralphs", + address: "6225 Magnolia Ave, Riverside, CA 92506", + coordinate: CLLocationCoordinate2D(latitude: 33.9524, longitude: -117.3698), locationId: nil), + StoreAnnotation(id: "ralphs_arlington", name: "Ralphs", chain: "Ralphs", + address: "5905 Arlington Ave, Riverside, CA 92504", + coordinate: CLLocationCoordinate2D(latitude: 33.9989, longitude: -117.3929), locationId: nil), + StoreAnnotation(id: "food4less_merrill", name: "Food 4 Less", chain: "Food 4 Less", + address: "3750 Merrill Ave, Riverside, CA 92506", + coordinate: CLLocationCoordinate2D(latitude: 33.9292, longitude: -117.3812), locationId: nil), + StoreAnnotation(id: "ralphs_central", name: "Ralphs", chain: "Ralphs", + address: "3650 Central Ave, Riverside, CA 92506", + coordinate: CLLocationCoordinate2D(latitude: 33.9453, longitude: -117.3957), locationId: nil), + StoreAnnotation(id: "ralphs_magnolia2", name: "Ralphs", chain: "Ralphs", + address: "12061 Magnolia Ave, Riverside, CA 92503", + coordinate: CLLocationCoordinate2D(latitude: 33.9993, longitude: -117.4229), locationId: nil), +] + +// MARK: - Stater Bros. +private let staterBrosStoresNearUCR: [StoreAnnotation] = [ + StoreAnnotation(id: "stater_1", name: "Stater Bros.", chain: "Stater Bros.", + address: "2841 Mary St, Riverside, CA 92506", + coordinate: CLLocationCoordinate2D(latitude: 33.9859162, longitude: -117.341186), locationId: nil), + StoreAnnotation(id: "stater_2", name: "Stater Bros.", chain: "Stater Bros.", + address: "4488 Magnolia Ave, Riverside, CA 92501", + coordinate: CLLocationCoordinate2D(latitude: 33.934523, longitude: -117.3865065), locationId: nil), + StoreAnnotation(id: "stater_3", name: "Stater Bros.", chain: "Stater Bros.", + address: "315 E Alessandro Blvd, Riverside, CA 92508", + coordinate: CLLocationCoordinate2D(latitude: 33.9142125, longitude: -117.3281308), locationId: nil), + StoreAnnotation(id: "stater_4", name: "Stater Bros.", chain: "Stater Bros.", + address: "12270 Sycamore Canyon Rd, Riverside, CA 92503", + coordinate: CLLocationCoordinate2D(latitude: 33.9483928, longitude: -117.2622614), locationId: nil), + StoreAnnotation(id: "stater_5", name: "Stater Bros.", chain: "Stater Bros.", + address: "4680 La Sierra Ave, Riverside, CA 92505", + coordinate: CLLocationCoordinate2D(latitude: 33.9976104, longitude: -117.4055226), locationId: nil), + StoreAnnotation(id: "stater_6", name: "Stater Bros.", chain: "Stater Bros.", + address: "2995 Iowa Ave, Riverside, CA 92507", + coordinate: CLLocationCoordinate2D(latitude: 34.0327531, longitude: -117.3203453), locationId: nil), +] + +// MARK: - Sprouts Farmers Market +private let sproutsStoresNearUCR: [StoreAnnotation] = [ + StoreAnnotation(id: "sprouts_1", name: "Sprouts Farmers Market", chain: "Sprouts", + address: "475 E Alessandro Blvd, Riverside, CA 92508", + coordinate: CLLocationCoordinate2D(latitude: 33.9139385, longitude: -117.3233031), locationId: nil), + StoreAnnotation(id: "sprouts_2", name: "Sprouts Farmers Market", chain: "Sprouts", + address: "12630 Day St, Moreno Valley, CA 92553", + coordinate: CLLocationCoordinate2D(latitude: 33.942023, longitude: -117.242369), locationId: nil), +] + +// MARK: - ALDI +private let aldiStoresNearUCR: [StoreAnnotation] = [ + StoreAnnotation(id: "aldi_1", name: "ALDI", chain: "ALDI", + address: "3750 Tyler St, Riverside, CA 92503", + coordinate: CLLocationCoordinate2D(latitude: 33.936734, longitude: -117.276131), locationId: nil), + StoreAnnotation(id: "aldi_2", name: "ALDI", chain: "ALDI", + address: "13460 Perris Blvd, Moreno Valley, CA 92553", + coordinate: CLLocationCoordinate2D(latitude: 33.910493, longitude: -117.461808), locationId: nil), + StoreAnnotation(id: "aldi_3", name: "ALDI", chain: "ALDI", + address: "1290 W Colton Ave, Redlands, CA 92374", + coordinate: CLLocationCoordinate2D(latitude: 34.064855, longitude: -117.272675), locationId: nil), +] + +// MARK: - Smart & Final +private let smartFinalStoresNearUCR: [StoreAnnotation] = [ + StoreAnnotation(id: "smartfinal_1", name: "Smart & Final", chain: "Smart & Final", + address: "5202 Arlington Ave, Riverside, CA 92504", + coordinate: CLLocationCoordinate2D(latitude: 33.9444932, longitude: -117.4159), locationId: nil), + StoreAnnotation(id: "smartfinal_2", name: "Smart & Final", chain: "Smart & Final", + address: "3310 Vine St, Riverside, CA 92507", + coordinate: CLLocationCoordinate2D(latitude: 33.9414981, longitude: -117.2803836), locationId: nil), + StoreAnnotation(id: "smartfinal_3", name: "Smart & Final", chain: "Smart & Final", + address: "2744 Canyon Springs Pkwy, Riverside, CA 92507", + coordinate: CLLocationCoordinate2D(latitude: 33.9834495, longitude: -117.3650503), locationId: nil), +] + +// MARK: - 99 Ranch Market +private let ranchMarketStoresNearUCR: [StoreAnnotation] = [ + StoreAnnotation(id: "ranch_1", name: "99 Ranch Market", chain: "99 Ranch", + address: "430 McKinley St, Corona, CA 92879", + coordinate: CLLocationCoordinate2D(latitude: 33.8886384, longitude: -117.5218049), locationId: nil), + StoreAnnotation(id: "ranch_2", name: "99 Ranch Market", chain: "99 Ranch", + address: "4956 Hamner Ave, Eastvale, CA 91752", + coordinate: CLLocationCoordinate2D(latitude: 33.9988899, longitude: -117.5559616), locationId: nil), +] + +private let walmartStoresNearUCR: [StoreAnnotation] = [ + StoreAnnotation(id: "walmart_1", name: "Walmart Supercenter", chain: "Walmart", + address: "12721 Moreno Beach Dr, Moreno Valley, CA 92555", + coordinate: CLLocationCoordinate2D(latitude: 33.9384322, longitude: -117.2861652), locationId: nil), + StoreAnnotation(id: "walmart_2", name: "Walmart Supercenter", chain: "Walmart", + address: "5200 Van Buren Blvd, Riverside, CA 92503", + coordinate: CLLocationCoordinate2D(latitude: 34.0497547, longitude: -117.3065025), locationId: nil), + StoreAnnotation(id: "walmart_3", name: "Walmart Supercenter", chain: "Walmart", + address: "8844 Limonite Ave, Jurupa Valley, CA 92509", + coordinate: CLLocationCoordinate2D(latitude: 34.0762367, longitude: -117.3733901), locationId: nil), + StoreAnnotation(id: "walmart_4", name: "Walmart Supercenter", chain: "Walmart", + address: "1290 E Ontario Ave, Corona, CA 92881", + coordinate: CLLocationCoordinate2D(latitude: 33.9371558, longitude: -117.4554553), locationId: nil), + StoreAnnotation(id: "walmart_5", name: "Walmart Supercenter", chain: "Walmart", + address: "479 N McKinley St, Corona, CA 92879", + coordinate: CLLocationCoordinate2D(latitude: 33.9739612, longitude: -117.5904619), locationId: nil), + StoreAnnotation(id: "walmart_6", name: "Walmart Supercenter", chain: "Walmart", + address: "1366 S Riverside Ave, Rialto, CA 92376", + coordinate: CLLocationCoordinate2D(latitude: 33.8061034, longitude: -117.2295603), locationId: nil), + StoreAnnotation(id: "walmart_7", name: "Walmart Supercenter", chain: "Walmart", + address: "725 N Tippecanoe Ave, San Bernardino, CA 92410", + coordinate: CLLocationCoordinate2D(latitude: 34.1378079, longitude: -117.1946666), locationId: nil), +] + +// MARK: - MapView + struct MapView: View { + @EnvironmentObject private var auth: AuthManager @StateObject private var locationManager = LocationManager() + private let ucrCoordinate = CLLocationCoordinate2D(latitude: 33.9737, longitude: -117.3281) + @State private var position: MapCameraPosition = .region( MKCoordinateRegion( - center: CLLocationCoordinate2D(latitude: 33.978194, longitude: -117.367861), - span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) + center: CLLocationCoordinate2D(latitude: 33.9737, longitude: -117.3281), + span: MKCoordinateSpan(latitudeDelta: 0.15, longitudeDelta: 0.15) ) ) - @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" - ) - ] + @State private var krogerStores: [StoreAnnotation] = [] + @State private var isLoadingStores = false + @State private var selectedStore: StoreAnnotation? = nil + @State private var errorText: String? = nil + + // Collapsible section state + @State private var krogerExpanded = true + @State private var walmartExpanded = false + @State private var staterExpanded = false + @State private var sproutsExpanded = false + @State private var aldiExpanded = false + @State private var smartFinalExpanded = false + @State private var ranchExpanded = false + + private var allStores: [StoreAnnotation] { krogerStoresNearUCR + walmartStoresNearUCR + staterBrosStoresNearUCR + sproutsStoresNearUCR + aldiStoresNearUCR + smartFinalStoresNearUCR + ranchMarketStoresNearUCR } + private var centerCoordinate: CLLocationCoordinate2D { locationManager.userLocation ?? ucrCoordinate } var body: some View { List { - Section { - CardContainer { - Map(position: $position) { - UserAnnotation() + mapSection + if let store = selectedStore { selectedStoreSection(store) } + krogerSection + walmartSection + staterBrosSection + sproutsSection + aldiSection + smartFinalSection + ranchMarketSection + locationSection + } + .navigationTitle("ShopWise") + .appToolbar() + .task { } // kroger stores are hardcoded — swap for live fetch once kroger_locations is populated + .onAppear { locationManager.requestPermission() } + .onChange(of: locationManager.userLocation?.latitude ?? 0) { _, lat in + guard lat != 0 else { return } + centerOnUser() + } + } - ForEach(stores) { store in - Marker(store.name, coordinate: store.coordinate) + // MARK: - Sections (broken out to help type checker) + + private var mapSection: some View { + Section { + CardContainer { + Map(position: $position) { + UserAnnotation() + ForEach(allStores) { store in + Annotation(store.name, coordinate: store.coordinate) { + StorePin(store: store, isSelected: selectedStore?.id == store.id) + .onTapGesture { + withAnimation { selectedStore = store } + focusOn(store.coordinate) + } } } - .frame(height: 260) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } - .listRowInsets(EdgeInsets()) + .frame(height: 280) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay(alignment: .topTrailing) { locationButton } } + .listRowInsets(EdgeInsets()) + } + } + + private var locationButton: some View { + Button { centerOnUser() } label: { + Image(systemName: "location.fill") + .padding(10) + .background(.regularMaterial) + .clipShape(Circle()) + } + .padding(10) + .disabled(locationManager.userLocation == nil) + } - 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() + private func selectedStoreSection(_ store: StoreAnnotation) -> some View { + Section { + CardContainer { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + let cardAsset = storeLogoAsset(for: store.chain) + if let cardAsset, UIImage(named: cardAsset) != nil { + Image(cardAsset) + .resizable() + .scaledToFit() + .padding(4) + .frame(width: 44, height: 44) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } else { + Image(systemName: store.icon) + .font(.title2) + .foregroundStyle(store.markerTint) + .frame(width: 44, height: 44) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 10)) } + VStack(alignment: .leading, spacing: 2) { + Text(store.name).font(.headline) + Text(store.chain).font(.subheadline).foregroundStyle(.secondary) + } + Spacer() + if let dist = distanceText(to: store) { + Text(dist) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8).padding(.vertical, 4) + .background(Color(.systemGray6)) + .clipShape(Capsule()) + } + } + if !store.address.isEmpty { + Label(store.address, systemImage: "mappin") + .font(.caption).foregroundStyle(.secondary) + } + Button { openInMaps(store) } label: { + Label("Get Directions", systemImage: "arrow.triangle.turn.up.right.circle") + .frame(maxWidth: .infinity) } - .buttonStyle(.plain) + .buttonStyle(.borderedProminent) + .tint(store.markerTint) } } + } + } - Section("Location") { - Button { - centerOnUser() - } label: { - Label("Center on Me", systemImage: "location.fill") - .frame(maxWidth: .infinity) - } - .disabled(locationManager.userLocation == nil) + private var krogerSection: some View { + Section { + DisclosureGroup(isExpanded: $krogerExpanded) { + ForEach(krogerStoresNearUCR) { store in storeRow(store) } + } label: { + sectionHeader(title: "Kroger Stores Nearby", count: krogerStoresNearUCR.count, isExpanded: krogerExpanded) + } + } + } + + private var walmartSection: some View { + Section { + DisclosureGroup(isExpanded: $walmartExpanded) { + ForEach(walmartStoresNearUCR) { store in storeRow(store) } + } label: { + sectionHeader(title: "Walmart Stores Nearby", count: walmartStoresNearUCR.count, isExpanded: walmartExpanded) + } + } + } - HStack { - Text("Permission") - Spacer() - Text(locationStatusText) - .foregroundStyle(.secondary) + private var staterBrosSection: some View { + Section { + DisclosureGroup(isExpanded: $staterExpanded) { + ForEach(staterBrosStoresNearUCR) { store in storeRow(store) } + } label: { + sectionHeader(title: "Stater Bros. Stores Nearby", count: staterBrosStoresNearUCR.count, isExpanded: staterExpanded) + } + } + } + + private var sproutsSection: some View { + Section { + DisclosureGroup(isExpanded: $sproutsExpanded) { + ForEach(sproutsStoresNearUCR) { store in storeRow(store) } + } label: { + sectionHeader(title: "Sprouts Nearby", count: sproutsStoresNearUCR.count, isExpanded: sproutsExpanded) + } + } + } + + private var aldiSection: some View { + Section { + DisclosureGroup(isExpanded: $aldiExpanded) { + ForEach(aldiStoresNearUCR) { store in storeRow(store) } + } label: { + sectionHeader(title: "ALDI Stores Nearby", count: aldiStoresNearUCR.count, isExpanded: aldiExpanded) + } + } + } + + private var smartFinalSection: some View { + Section { + DisclosureGroup(isExpanded: $smartFinalExpanded) { + ForEach(smartFinalStoresNearUCR) { store in storeRow(store) } + } label: { + sectionHeader(title: "Smart & Final Nearby", count: smartFinalStoresNearUCR.count, isExpanded: smartFinalExpanded) + } + } + } + + private var ranchMarketSection: some View { + Section { + DisclosureGroup(isExpanded: $ranchExpanded) { + ForEach(ranchMarketStoresNearUCR) { store in storeRow(store) } + } label: { + sectionHeader(title: "99 Ranch Market Nearby", count: ranchMarketStoresNearUCR.count, isExpanded: ranchExpanded) + } + } + } + + private var locationSection: some View { + Section("Location") { + HStack { + Text("Permission") + Spacer() + Text(locationStatusText).foregroundStyle(.secondary) + } + } + } + + // MARK: - Section header + + private func sectionHeader(title: String, count: Int, isExpanded: Bool) -> some View { + HStack { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Spacer() + Text("\(count)") + .font(.caption.weight(.medium)) + .foregroundStyle(.white) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Color.blue.opacity(0.8)) + .clipShape(Capsule()) + } + .contentShape(Rectangle()) + } + + // MARK: - Store row + + private func storeRow(_ store: StoreAnnotation) -> some View { + Button { + withAnimation { selectedStore = store } + focusOn(store.coordinate) + } label: { + HStack(spacing: 12) { + let rowAsset = storeLogoAsset(for: store.chain) + if let rowAsset, UIImage(named: rowAsset) != nil { + Image(rowAsset) + .resizable() + .scaledToFit() + .padding(4) + .frame(width: 38, height: 38) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + Image(systemName: store.icon) + .font(.system(size: 18)) + .foregroundStyle(store.markerTint) + .frame(width: 38, height: 38) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 8)) } + VStack(alignment: .leading, spacing: 2) { + Text(store.name).font(.subheadline.weight(.medium)) + if !store.address.isEmpty { + Text(store.address).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + } + Spacer() + if let dist = distanceText(to: store) { + Text(dist).font(.caption).foregroundStyle(.secondary) + } + Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary) } } - .navigationTitle("ShopWise") - .appToolbar() - .onAppear { - locationManager.requestPermission() + .buttonStyle(.plain) + } + + // MARK: - Data loading + + @MainActor + private func loadKrogerStores() async { + guard !isLoadingStores else { return } + isLoadingStores = true + errorText = nil + do { + let fetched = try await auth.fetchNearbyKrogerStores( + near: centerCoordinate, radiusDegrees: 0.2 + ) + krogerStores = fetched.compactMap { store -> StoreAnnotation? in + guard let coord = store.coordinate else { return nil } + return StoreAnnotation( + id: "kroger_\(store.locationId)", + name: store.displayName, + chain: store.chain ?? "Kroger", + address: store.displayAddress, + coordinate: coord, + locationId: String(store.locationId) + ) + } + } catch { + krogerStores = [] } - .onChange(of: locationManager.userLocation?.latitude ?? 0) { _, newLatitude in - guard newLatitude != 0 else { return } - centerOnUser() + isLoadingStores = false + } + + // MARK: - Helpers + + private func focusOn(_ coordinate: CLLocationCoordinate2D) { + withAnimation { + position = .region(MKCoordinateRegion( + center: coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) + )) + } + } + + private func centerOnUser() { + guard let loc = locationManager.userLocation else { return } + focusOn(loc) + } + + private func distanceText(to store: StoreAnnotation) -> String? { + guard let userLoc = locationManager.userLocation else { return nil } + let user = CLLocation(latitude: userLoc.latitude, longitude: userLoc.longitude) + let dest = CLLocation(latitude: store.coordinate.latitude, longitude: store.coordinate.longitude) + let miles = user.distance(from: dest) / 1609.34 + return String(format: "%.1f mi", miles) + } + + private func openInMaps(_ store: StoreAnnotation) { + // Use Maps URL scheme — avoids MKPlacemark deprecation entirely + let query = store.address.isEmpty + ? "\(store.coordinate.latitude),\(store.coordinate.longitude)" + : store.address + let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let urlStr = "maps://?daddr=\(encoded)&dirflg=d" + if let url = URL(string: urlStr) { + UIApplication.shared.open(url) } } 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" + case .notDetermined: return "Not Requested" + case .restricted: return "Restricted" + case .denied: return "Denied" + case .authorizedAlways, .authorizedWhenInUse: return "Allowed" + @unknown default: return "Unknown" } } +} - private func centerOnUser() { - guard let user = locationManager.userLocation else { return } +// MARK: - Store pin - position = .region( - MKCoordinateRegion( - center: user, - span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - ) - ) +struct StorePin: View { + let store: StoreAnnotation + let isSelected: Bool + + private let size: CGFloat = 40 + private let selectedSize: CGFloat = 52 + + var body: some View { + let pinSize = isSelected ? selectedSize : size + + ZStack { + // White circle background + Circle() + .fill(.white) + .frame(width: pinSize, height: pinSize) + .shadow(color: store.markerTint.opacity(0.4), + radius: isSelected ? 8 : 3, + x: 0, y: 2) + .overlay( + Circle() + .strokeBorder(store.markerTint, + lineWidth: isSelected ? 3 : 2) + ) + + // Logo image if asset exists, otherwise SF Symbol fallback + let asset = storeLogoAsset(for: store.chain) ?? store.logoAsset + if UIImage(named: asset) != nil { + Image(asset) + .resizable() + .scaledToFit() + .frame(width: pinSize * 0.65, height: pinSize * 0.65) + .clipShape(Circle()) + } else { + Image(systemName: store.icon) + .font(.system(size: isSelected ? 20 : 15, weight: .semibold)) + .foregroundStyle(store.markerTint) + } + } + .animation(.spring(duration: 0.2), value: isSelected) } } diff --git a/SWFrontUI/ShopwiseFrontEndUI/ProfileView.swift b/SWFrontUI/ShopwiseFrontEndUI/ProfileView.swift index 80b9d3e..5510e17 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/ProfileView.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/ProfileView.swift @@ -6,6 +6,11 @@ struct ProfileView: View { @EnvironmentObject private var cartStore: CartStore @Environment(\.openURL) private var openURL + @State private var showChangePassword = false + @State private var showDeleteConfirm = false + @State private var isDeletingAccount = false + @State private var deleteError: String? = nil + var body: some View { List { Section("Account") { @@ -17,13 +22,19 @@ struct ProfileView: View { VStack(alignment: .leading) { Text(greetingText) .font(.headline) - Text(auth.userEmail ?? "No email") .font(.caption) .foregroundStyle(.secondary) } } .padding(.vertical, 6) + + // Change password — pushes to inline view + NavigationLink { + ChangePasswordView() + } label: { + Label("Change Password", systemImage: "lock.rotation") + } } Section("Preferences") { @@ -55,22 +66,66 @@ struct ProfileView: View { } label: { Text("Sign Out") } + + Button(role: .destructive) { + showDeleteConfirm = true + } label: { + HStack { + if isDeletingAccount { ProgressView() } + Text("Delete Account") + } + } + .disabled(isDeletingAccount) + + if let err = deleteError { + Text(err) + .font(.caption) + .foregroundStyle(.red) + } + } footer: { + Text("Deleting your account is permanent and cannot be undone.") + .font(.caption) } } .navigationTitle("Account & Settings") + .confirmationDialog( + "Delete Account", + isPresented: $showDeleteConfirm, + titleVisibility: .visible + ) { + Button("Delete My Account", role: .destructive) { + Task { await submitDeleteAccount() } + } + Button("Cancel", role: .cancel) { } + } message: { + Text("This will permanently delete your account and all your data. This cannot be undone.") + } + } + + // MARK: - Helpers + + @MainActor + private func submitDeleteAccount() async { + isDeletingAccount = true + deleteError = nil + do { + try await auth.deleteAccount() + } catch { + deleteError = error.localizedDescription + isDeletingAccount = false + } } private var appVersion: String { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String - let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String - if let version, let build { - return "\(version) (\(build))" - } + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String + if let version, let build { return "\(version) (\(build))" } return version ?? "1.0" } private var greetingText: String { - if let name = auth.userName, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let name = auth.userName, + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return name } return "Hello" @@ -82,3 +137,84 @@ struct ProfileView: View { } } } + +// MARK: - Change Password screen + +struct ChangePasswordView: View { + @EnvironmentObject private var auth: AuthManager + @Environment(\.dismiss) private var dismiss + + @State private var newPassword = "" + @State private var confirmPassword = "" + @State private var isLoading = false + @State private var success = false + @State private var errorMessage: String? = nil + + var body: some View { + List { + Section { + SecureField("New password", text: $newPassword) + .textContentType(.newPassword) + SecureField("Confirm new password", text: $confirmPassword) + .textContentType(.newPassword) + } footer: { + Text("Minimum 6 characters.") + } + + Section { + Button { + Task { await submit() } + } label: { + HStack { + if isLoading { ProgressView() } + Text("Update Password") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(isLoading || newPassword.isEmpty || confirmPassword.isEmpty) + } + + if success { + Section { + Label("Password updated successfully!", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } + + if let err = errorMessage { + Section { + Text(err).foregroundStyle(.red).font(.callout) + } + } + } + .navigationTitle("Change Password") + .navigationBarTitleDisplayMode(.inline) + } + + @MainActor + private func submit() async { + errorMessage = nil + success = false + + guard newPassword == confirmPassword else { + errorMessage = "Passwords don't match." + return + } + guard newPassword.count >= 6 else { + errorMessage = "Password must be at least 6 characters." + return + } + + isLoading = true + do { + try await auth.changePassword(to: newPassword) + success = true + newPassword = "" + confirmPassword = "" + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/RecipeView.swift b/SWFrontUI/ShopwiseFrontEndUI/RecipeView.swift index 2f6f15a..8f3ebfe 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/RecipeView.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/RecipeView.swift @@ -1,79 +1,94 @@ import SwiftUI + +// MARK: - Store filter + +enum ShoppingMode: String, CaseIterable { + case value = "Value" + case timeSave = "Time Save" + var label: String { rawValue } +} + +/// Result of the Time Save store-selection algorithm. +/// Maps each canonical ingredient name to the best match from the chosen store set. +struct TimeSavePlan { + /// Ordered list of stores selected (2–4 max), sorted by items covered descending. + let stores: [String] + /// Per-ingredient: the best match from the selected stores (fallback to cheapest if uncovered). + let matchFor: [String: ScrapedRecipeMatch] +} + struct RecipeView: View { @EnvironmentObject private var auth: AuthManager @EnvironmentObject private var cartStore: CartStore - + @State private var query = "" @State private var recipes: [RecipeRow] = [] @State private var expandedID: Int? = nil @State private var isLoading = false @State private var errorText: String? = nil @State private var searchTask: Task? = nil - + @State private var pageSize = 20 @State private var offset = 0 @State private var hasMore = true @State private var isLoadingMore = false @State private var excludedIngredientsByRecipe: [Int: Set] = [:] @State private var expandedInstructionIds: Set = [] - + + // Shopping mode – persists across recipe expansions + @State private var shoppingMode: ShoppingMode = .value + + // Scraped matches keyed by recipe_id + @State private var matchesByRecipe: [Int: [ScrapedRecipeMatch]] = [:] + @State private var loadingMatchesFor: Set = [] + private var filtered: [RecipeRow] { - if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return recipes - } + if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return recipes } return recipes.filter { $0.title.localizedCaseInsensitiveContains(query) } } - + var body: some View { - List { - content - } - .listStyle(.plain) - .navigationTitle("ShopWise") - .searchable(text: $query, prompt: "Search recipes…") - .appToolbar() - .task { - await loadRecipes(reset: true) - } - .onChange(of: query) { _, _ in - searchTask?.cancel() - searchTask = Task { - try? await Task.sleep(nanoseconds: 400_000_000) - if Task.isCancelled { return } - await loadRecipes(reset: true) + List { content } + .listStyle(.plain) + .navigationTitle("ShopWise") + .searchable(text: $query, prompt: "Search recipes…") + .appToolbar() + .task { await loadRecipes(reset: true) } + .onChange(of: query) { _, _ in + searchTask?.cancel() + searchTask = Task { + try? await Task.sleep(nanoseconds: 400_000_000) + if Task.isCancelled { return } + await loadRecipes(reset: true) + } } - } } - + @ViewBuilder private var content: some View { - if isLoading { - HStack { - Spacer() - ProgressView("Loading…") - Spacer() - } - .listRowSeparator(.hidden) - + HStack { Spacer(); ProgressView("Loading…"); Spacer() } + .listRowSeparator(.hidden) } else if let errorText { - Text(errorText) - .foregroundStyle(.red) - + Text(errorText).foregroundStyle(.red) } else { ForEach(Array(filtered.enumerated()), id: \.element.id) { index, recipe in recipeSection(index: index, recipe: recipe) } } } - + private func recipeSection(index: Int, recipe: RecipeRow) -> some View { Section { - Button { withAnimation(.snappy) { - expandedID = (expandedID == recipe.id) ? nil : recipe.id + if expandedID == recipe.id { + expandedID = nil + } else { + expandedID = recipe.id + loadMatchesIfNeeded(for: recipe.id) + } } } label: { recipeCard(recipe) @@ -81,80 +96,178 @@ struct RecipeView: View { .buttonStyle(.plain) .onAppear { if index == filtered.count - 1 { - Task { - await loadRecipes(reset: false) - } + Task { await loadRecipes(reset: false) } } } - + if expandedID == recipe.id { recipeExpanded(recipe) } } } - + private func recipeCard(_ recipe: RecipeRow) -> some View { CardContainer { HStack(spacing: 14) { - - if let urlString = recipe.imageURL, - let url = URL(string: urlString) { - + if let urlString = recipe.imageURL, let url = URL(string: urlString) { AsyncImage(url: url) { phase in switch phase { - case .success(let image): - image.resizable().scaledToFill() - case .failure(_): - Image(systemName: "fork.knife") - default: - ProgressView() + case .success(let image): image.resizable().scaledToFill() + case .failure: Image(systemName: "fork.knife") + default: ProgressView() } } .frame(width: 67, height: 67) .background(Color(.systemGray5)) .clipShape(RoundedRectangle(cornerRadius: 14)) - } else { RoundedRectangle(cornerRadius: 14) .fill(Color(.systemGray5)) .frame(width: 67, height: 67) - .overlay( - Image(systemName: "fork.knife") - .foregroundStyle(.secondary) - ) + .overlay(Image(systemName: "fork.knife").foregroundStyle(.secondary)) } - + VStack(alignment: .leading, spacing: 6) { - Text(recipe.title) - .font(.headline) - .lineLimit(2) + Text(recipe.title).font(.headline).lineLimit(2) } - + Spacer() - + Image(systemName: expandedID == recipe.id ? "chevron.up" : "chevron.down") .foregroundStyle(.secondary) } } } - + private func recipeExpanded(_ recipe: RecipeRow) -> some View { CardContainer { VStack(alignment: .leading, spacing: 12) { - Text("Ingredients") - .font(.headline) - - ForEach(recipe.ingredientList, id: \.self) { item in - ingredientRow(recipeId: recipe.id, item: item) + + HStack { + Text("Ingredients").font(.headline) + Spacer() + } + + // Mode picker – only shown once matches are loaded + let allMatches = matchesByRecipe[recipe.id] ?? [] + if !allMatches.isEmpty { + HStack(spacing: 4) { + ForEach(ShoppingMode.allCases, id: \.self) { mode in + let isSelected = shoppingMode == mode + Button { + shoppingMode = mode + } label: { + HStack(spacing: 5) { + Image(systemName: mode == .value ? "tag.fill" : "clock.arrow.2.circlepath") + .font(.system(size: 12, weight: .semibold)) + Text(mode.label) + .font(.system(size: 14, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isSelected ? Color.primary : Color.clear) + ) + .foregroundStyle(isSelected ? Color(UIColor.systemBackground) : Color.secondary) + } + .buttonStyle(.plain) + } + } + .padding(4) + .background(Color(.systemGray5)) + .clipShape(RoundedRectangle(cornerRadius: 13)) } - if let instructions = recipe.instructions, - !instructions.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Divider() + let isLoadingMatches = loadingMatchesFor.contains(recipe.id) + let (allGrouped, ingredients) = processMatches(allMatches) + // For Time Save mode, compute the optimal store plan once here. + let timeSavePlan: TimeSavePlan? = shoppingMode == .timeSave + ? computeTimeSavePlan(allGrouped: allGrouped, ingredients: ingredients) + : nil + + if isLoadingMatches { + HStack { + Spacer() + VStack(spacing: 6) { + ProgressView() + Text("Finding products…") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 8) + + } else if allMatches.isEmpty { + ForEach(recipe.ingredientList, id: \.self) { item in + ingredientRow(recipeId: recipe.id, item: item) + } + + } else { + // Store summary banner — shown for both modes + let valueStores: [String] = { + // Unique stores that would be visited if picking cheapest per ingredient + var seen = Set() + var result: [String] = [] + for item in ingredients { + if let store = (allGrouped[item] ?? []).first?.matched_store, + seen.insert(store).inserted { + result.append(store) + } + } + return result + }() + + let displayStores = timeSavePlan?.stores ?? valueStores + if !displayStores.isEmpty { + HStack(spacing: 5) { + Image(systemName: "mappin.and.ellipse") + .font(.caption.weight(.semibold)) + .foregroundStyle(shoppingMode == .timeSave ? Color.blue : Color.green) + Text("\(displayStores.count) stop\(displayStores.count == 1 ? "" : "s"): \(displayStores.joined(separator: " · "))") + .font(.caption) + .foregroundStyle(Color.primary.opacity(0.75)) + } .padding(.vertical, 2) + } + + let orderedKeys = ingredients.filter { allGrouped[$0] != nil } + ForEach(orderedKeys, id: \.self) { item in + let allForItem = allGrouped[item] ?? [] + // Value: all matches (cheapest first). Time Save: single best match from plan. + let displayMatches: [ScrapedRecipeMatch] = { + if let plan = timeSavePlan { + if let best = plan.matchFor[item] { return [best] } + return allForItem.prefix(1).map { $0 } + } + return allForItem + }() + IngredientMatchRow( + ingredient: item, + matches: displayMatches, + isExcluded: excludedIngredientsByRecipe[recipe.id]?.contains(item) == true, + onToggle: { toggleIngredient(recipeId: recipe.id, item: item) }, + onAdd: { match in + cartStore.add( + recipeId: String(recipe.id), + recipeTitle: recipe.title, + name: match.matched_name ?? item, + unit: match.matched_size ?? "", + price: match.min_price ?? 0 + ) + } + ) + } + } + + if let instructions = recipe.instructions, + !instructions.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Divider().padding(.vertical, 2) + Button { - withAnimation(.snappy){ + withAnimation(.snappy) { toggleInstructions(recipe.id) } } label: { @@ -167,7 +280,7 @@ struct RecipeView: View { } } .buttonStyle(.plain) - + if expandedInstructionIds.contains(recipe.id) { if !recipe.instructionSteps.isEmpty { ForEach(Array(recipe.instructionSteps.enumerated()), id: \.offset) { index, step in @@ -176,23 +289,44 @@ struct RecipeView: View { .foregroundStyle(.secondary) } } else { - Text(instructions) - .font(.subheadline) - .foregroundStyle(.secondary) + Text(instructions).font(.subheadline).foregroundStyle(.secondary) } } } - + Button { let excluded = excludedIngredientsByRecipe[recipe.id] ?? [] - for item in recipe.ingredientList where !excluded.contains(item) { - cartStore.add( - recipeId: String(recipe.id), - recipeTitle: recipe.title, - name: item, - unit: "", - price: 0 - ) + let allM = matchesByRecipe[recipe.id] ?? [] + let (allGrouped, processedIngredients) = processMatches(allM) + let ingredients = allM.isEmpty ? recipe.ingredientList : processedIngredients + // For Time Save, resolve the plan once so every ingredient gets the right store. + let plan: TimeSavePlan? = shoppingMode == .timeSave + ? computeTimeSavePlan(allGrouped: allGrouped, ingredients: processedIngredients) + : nil + + for item in ingredients where !excluded.contains(item) { + let allForItem = allGrouped[item] ?? [] + let top: ScrapedRecipeMatch? = { + if let plan { + return plan.matchFor[item] ?? allForItem.first + } + return allForItem.first // Value: cheapest + }() + if let top { + cartStore.add( + recipeId: String(recipe.id), + recipeTitle: recipe.title, + name: top.matched_name ?? item, + unit: top.matched_size ?? "", + price: top.min_price ?? 0 + ) + } else { + cartStore.add( + recipeId: String(recipe.id), + recipeTitle: recipe.title, + name: item, unit: "", price: 0 + ) + } } } label: { let excluded = excludedIngredientsByRecipe[recipe.id] ?? [] @@ -209,17 +343,175 @@ struct RecipeView: View { } } } + + // MARK: - Helpers + + /// Normalizes an ingredient string for duplicate detection. + /// Strips leading quantities/units and trailing qualifiers so that + /// "Freshly ground black pepper" and "freshly ground pepper" collapse + /// to the same key, as do "2 tsp. kosher salt" and "kosher salt, divided". + private func normalizedKey(_ raw: String) -> String { + var s = raw.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + // Strip leading quantity + optional unit (e.g. "2 tbsp. ", "3/4 tsp ", "1/2 cup ") + let quantityUnit = #"^[\d\u{00BC}\u{00BD}\u{00BE}\u{2153}\u{2154}\u{215B}-\u{215E}\/\.\s]+(tsp\.?|tbsp\.?|cups?|oz\.?|lbs?\.?|g|ml|l|pinch|dash|cloves?|slices?|pieces?)?\s*"# + if let range = s.range(of: quantityUnit, options: .regularExpression) { + s.removeSubrange(range) + } + // Strip trailing qualifiers like ", divided" / ", room temperature" / ", optional" + if let commaRange = s.range(of: ",") { + s = String(s[s.startIndex.. (grouped: [String: [ScrapedRecipeMatch]], ordered: [String]) { + var canonicalFor: [String: String] = [:] // normalizedKey -> first raw string seen + var dict: [String: [ScrapedRecipeMatch]] = [:] + var order: [String] = [] // insertion-order canonical keys + + for match in matches { + let key = normalizedKey(match.raw_ingredient) + if canonicalFor[key] == nil { + canonicalFor[key] = match.raw_ingredient + order.append(match.raw_ingredient) // record order on first encounter + } + let canonical = canonicalFor[key]! + dict[canonical, default: []].append(match) + } + + // Deduplicate within each group: keep one match per (store, price) pair. + let deduped = dict.mapValues { group -> [ScrapedRecipeMatch] in + var seen = Set() + return group.filter { m in + let k = "\(m.matched_store ?? "")|(\(m.min_price ?? -1))" + return seen.insert(k).inserted + } + } + return (deduped, order) + } + + // Convenience wrappers so existing call sites keep working. + private func groupedMatches(_ matches: [ScrapedRecipeMatch]) -> [String: [ScrapedRecipeMatch]] { + processMatches(matches).grouped + } + private func orderedIngredients(from matches: [ScrapedRecipeMatch]) -> [String] { + processMatches(matches).ordered + } + + /// Deterministic greedy set-cover: picks the fewest stores that together cover + /// all ingredients. Ties in coverage count are broken by which store gives the + /// lowest total price across its covered ingredients — so results never change + /// when toggling between modes. + private func computeTimeSavePlan( + allGrouped: [String: [ScrapedRecipeMatch]], + ingredients: [String] + ) -> TimeSavePlan { + // Build store -> cheapest price per ingredient it carries + var storeCheapest: [String: [String: Double]] = [:] + for (ingredient, matches) in allGrouped { + for match in matches { + guard let store = match.matched_store else { continue } + let price = match.min_price ?? Double.infinity + if storeCheapest[store] == nil { storeCheapest[store] = [:] } + if storeCheapest[store]![ingredient] == nil || + price < storeCheapest[store]![ingredient]! { + storeCheapest[store]![ingredient] = price + } + } + } + + // store -> set of ingredients covered + let storeCoverage: [String: Set] = storeCheapest.mapValues { Set($0.keys) } + + var selectedStores: [String] = [] + var covered = Set() + let allIngredients = Set(ingredients) + + while covered != allIngredients { + let remaining = allIngredients.subtracting(covered) + + // Score every candidate store: primary = most uncovered ingredients, + // tiebreak = lowest total price for those ingredients (deterministic) + var bestStore: String? = nil + var bestCount = 0 + var bestPrice = Double.infinity + + // Sort by name first so iteration order is deterministic before scoring + let sortedStores = storeCoverage.keys.sorted() + for store in sortedStores { + guard !selectedStores.contains(store), + let storeItems = storeCoverage[store] else { continue } + let newlyCovered = storeItems.intersection(remaining) + let count = newlyCovered.count + guard count > 0 else { continue } + + // Total price for ingredients this store would cover + let totalPrice = newlyCovered.reduce(0.0) { sum, ing in + sum + (storeCheapest[store]?[ing] ?? Double.infinity) + } + + if count > bestCount || (count == bestCount && totalPrice < bestPrice) { + bestCount = count + bestPrice = totalPrice + bestStore = store + } + } + + guard let chosen = bestStore, bestCount > 0 else { break } + selectedStores.append(chosen) + if let items = storeCoverage[chosen] { + covered.formUnion(items) + } + } + + // For each ingredient: pick cheapest match from any selected store, + // fall back to cheapest overall if no selected store carries it. + var matchFor: [String: ScrapedRecipeMatch] = [:] + for ingredient in ingredients { + let allForItem = allGrouped[ingredient] ?? [] + // Among selected-store matches, pick the cheapest one + let storeMatches = allForItem.filter { + selectedStores.contains($0.matched_store ?? "") + } + matchFor[ingredient] = storeMatches.min(by: { + ($0.min_price ?? Double.infinity) < ($1.min_price ?? Double.infinity) + }) ?? allForItem.first + } + + return TimeSavePlan(stores: selectedStores, matchFor: matchFor) + } + private func loadMatchesIfNeeded(for recipeId: Int) { + guard matchesByRecipe[recipeId] == nil, + !loadingMatchesFor.contains(recipeId) else { return } + loadingMatchesFor.insert(recipeId) + Task { + do { + let fetched = try await auth.fetchScrapedRecipeMatches(recipeId: recipeId) + await MainActor.run { + matchesByRecipe[recipeId] = fetched + loadingMatchesFor.remove(recipeId) + } + } catch { + await MainActor.run { + matchesByRecipe[recipeId] = [] + loadingMatchesFor.remove(recipeId) + } + } + } + } + private func toggleIngredient(recipeId: Int, item: String) { var set = excludedIngredientsByRecipe[recipeId] ?? [] - if set.contains(item) { - set.remove(item) - } else { - set.insert(item) - } + if set.contains(item) { set.remove(item) } else { set.insert(item) } excludedIngredientsByRecipe[recipeId] = set } - + private func toggleInstructions(_ recipeId: Int) { if expandedInstructionIds.contains(recipeId) { expandedInstructionIds.remove(recipeId) @@ -227,64 +519,146 @@ struct RecipeView: View { expandedInstructionIds.insert(recipeId) } } - + private func ingredientRow(recipeId: Int, item: String) -> some View { let isExcluded = excludedIngredientsByRecipe[recipeId]?.contains(item) == true - - return Button { - toggleIngredient(recipeId: recipeId, item: item) - } label: { + return Button { toggleIngredient(recipeId: recipeId, item: item) } label: { HStack(spacing: 10) { Image(systemName: isExcluded ? "minus.circle" : "checkmark.circle.fill") - .foregroundStyle(isExcluded ? .secondary : Color.green) + .foregroundStyle(isExcluded ? Color.secondary : Color.green) Text(item) - .foregroundStyle(isExcluded ? .secondary : .primary) + .foregroundStyle(isExcluded ? Color.secondary : Color.primary) .strikethrough(isExcluded, color: .secondary) } } .buttonStyle(.plain) } - + @MainActor private func loadRecipes(reset: Bool = true) async { if isLoadingMore { return } - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - + if reset { - offset = 0 - recipes = [] - hasMore = true - isLoading = true + offset = 0; recipes = []; hasMore = true; isLoading = true } else { if !hasMore { return } isLoadingMore = true } - errorText = nil - + do { let fetched = try await auth.fetchRecipes( search: trimmed.isEmpty ? nil : trimmed, - limit: pageSize, - offset: offset + limit: pageSize, offset: offset ) - - if reset { - recipes = fetched - } else { - recipes.append(contentsOf: fetched) - } - + if reset { recipes = fetched } else { recipes.append(contentsOf: fetched) } offset += fetched.count - if fetched.count < pageSize { - hasMore = false - } + if fetched.count < pageSize { hasMore = false } } catch { errorText = (error as NSError).localizedDescription } - isLoading = false isLoadingMore = false } } + +// MARK: - Ingredient row with Kroger match + +struct IngredientMatchRow: View { + let ingredient: String + let matches: [ScrapedRecipeMatch] + let isExcluded: Bool + let onToggle: () -> Void + let onAdd: (ScrapedRecipeMatch) -> Void + + @State private var selectedRank = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + + Button(action: onToggle) { + HStack(spacing: 10) { + Image(systemName: isExcluded ? "minus.circle" : "checkmark.circle.fill") + .foregroundStyle(isExcluded ? Color.secondary : Color.green) + Text(ingredient) + .font(.subheadline) + .foregroundStyle(isExcluded ? Color.secondary : Color.primary) + .strikethrough(isExcluded, color: .secondary) + Spacer() + } + } + .buttonStyle(.plain) + + if !matches.isEmpty && !isExcluded { + if matches.count > 1 { + Picker("", selection: $selectedRank) { + ForEach(matches.indices, id: \.self) { i in + Text(matches[i].displayPrice).tag(i) + } + } + .pickerStyle(.segmented) + } + + if selectedRank < matches.count { + let match = matches[selectedRank] + HStack(spacing: 10) { + AsyncImage(url: match.imageURL) { phase in + switch phase { + case .success(let img): + img.resizable().scaledToFill() + .frame(width: 44, height: 44).clipped() + default: + Image(systemName: "photo") + .frame(width: 44, height: 44) + .foregroundStyle(.tertiary) + } + } + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 2) { + Text(match.matched_name ?? "") + .font(.caption.weight(.medium)) + .lineLimit(2) + if let store = match.matched_store, !store.isEmpty { + Text(store) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.blue) + .lineLimit(1) + } + if let size = match.matched_size, !size.isEmpty { + Text(size).font(.caption2).foregroundStyle(.secondary) + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + Text(match.displayPrice) + .font(.caption.weight(.semibold)) + .foregroundStyle(.blue) + + Button { onAdd(match) } label: { + Image(systemName: "cart.badge.plus") + .font(.system(size: 14)) + .padding(6) + .background(Color.blue.opacity(0.12)) + .foregroundStyle(.blue) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + } + .padding(8) + .background(Color(.systemGray6).opacity(0.6)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + } + .padding(.vertical, 2) + // Reset the picker whenever the matches array changes (e.g. store filter switched). + // Without this, selectedRank can point past the end of the new array and nothing renders. + .onChange(of: matches.map { $0.id }) { _, _ in selectedRank = 0 } + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/SearchView.swift b/SWFrontUI/ShopwiseFrontEndUI/SearchView.swift index 0916478..eb8ece9 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/SearchView.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/SearchView.swift @@ -1,35 +1,54 @@ +import UIKit import SwiftUI +// MARK: - Store tab definition + +struct StoreTab: Identifiable, Equatable { + let id: String // store name or "All" + let label: String + let icon: String + + static let all: [StoreTab] = [ + StoreTab(id: "All", label: "All", icon: "square.grid.2x2"), + StoreTab(id: "Walmart", label: "Walmart", icon: "cart.fill"), + StoreTab(id: "Ralphs", label: "Ralphs", icon: "storefront.fill"), + StoreTab(id: "Stater Bros.", label: "Stater Bros",icon: "basket.fill"), + StoreTab(id: "Food4Less", label: "Food4Less", icon: "tag.fill"), + StoreTab(id: "Sprouts Farmers Market", label: "Sprouts", icon: "leaf.fill"), + StoreTab(id: "ALDI", label: "ALDI", icon: "dollarsign.circle.fill"), + StoreTab(id: "Costco", label: "Costco", icon: "shippingbox.fill"), + StoreTab(id: "99 Ranch Market", label: "99 Ranch", icon: "globe.asia.australia.fill"), + StoreTab(id: "Smart & Final", label: "Smart & Final", icon: "bag.fill"), + StoreTab(id: "Target", label: "Target", icon: "scope"), + ] +} + +// MARK: - SearchView + 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 selectedTab: StoreTab = StoreTab.all[0] + @State private var results: [ScrapedIngredient] = [] @State private var isLoading = false @State private var errorText: String? = nil - @State private var expandedItemId: Int? = 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) - .padding(.bottom, 4) - // Search bar (custom like your right screenshot) + // Search bar HStack(spacing: 10) { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) @@ -56,21 +75,21 @@ struct SearchView: View { .padding(.vertical, 12) .background(Color(.systemGray6)) .clipShape(RoundedRectangle(cornerRadius: 18)) - .padding(.bottom, 4) - // Category chips row + // Store tabs — scrollable 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 + ForEach(StoreTab.all) { tab in + StoreChip(tab: tab, isSelected: selectedTab == tab) { + selectedTab = tab + searchItems(reset: true) } } } .padding(.vertical, 2) + .padding(.horizontal, 1) } - + // Content if isLoading { HStack { @@ -83,40 +102,47 @@ struct SearchView: View { Text(errorText) .foregroundStyle(.red) .padding(.top, 10) + } else if results.isEmpty { + HStack { + Spacer() + VStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 36)) + .foregroundStyle(.tertiary) + Text("No results") + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.top, 40) } else { LazyVStack(spacing: 14) { - ForEach(Array(filteredResults.enumerated()), id: \.element.id) { index, item in + ForEach(Array(results.enumerated()), id: \.element.id) { index, item in ProductCard( - imageURL: bestImageURL(for: item), + imageURL: item.imageURL, title: item.name, - unit: unitText(for: item), - priceText: formatPrice(item.retail_price), - descriptionText: nil, // link this to Supabase later - itemCountText: nil, // link this to Supabase later - isExpanded: expandedItemId == item.id, - onToggle: { - withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { - if expandedItemId == item.id { - expandedItemId = nil - } else { - expandedItemId = item.id - } - } - }, + unit: item.quantity, + priceText: item.displayPrice, // var not func + storeName: selectedTab.id == "All" ? item.store : nil, onAdd: { - cartStore.add(id: String(item.id), name: item.name, unit: unitText(for: item) ?? "", price: item.retail_price ?? 0) + cartStore.add( + id: item.id, + name: item.name, + unit: item.quantity ?? "", + price: item.price ?? 0 + ) } ) .onAppear { - // When the LAST item appears, load more - if index == filteredResults.count - 1 { + if index == results.count - 1 { searchItems(reset: false) } } - } } + } + } .padding(.top, 4) } - + Spacer(minLength: 20) } .padding(.horizontal, 16) @@ -124,7 +150,7 @@ struct SearchView: View { } .background(Color(.systemBackground)) .navigationBarTitleDisplayMode(.inline) - .appToolbar() // <-- uses your existing Settings/Profile toolbar + .appToolbar() .onChange(of: query) { _, _ in searchItems(reset: true) } @@ -133,44 +159,9 @@ struct SearchView: View { } } - // 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: - Fetch from scraped_ingredients - // 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 { @@ -178,44 +169,26 @@ struct SearchView: View { results = [] hasMore = true } else { - // if we already know there are no more rows, stop if !hasMore { return } } - if reset { - isLoading = true - } else { - isLoadingMore = true - } + 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 + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let newItems = try await auth.searchScrapedIngredients( + query: trimmed, + store: selectedTab.id == "All" ? nil : selectedTab.id, 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 } @@ -230,162 +203,114 @@ struct SearchView: View { } } +// MARK: - Store chip -struct SearchCategoryChip: View { - let title: String +struct StoreChip: View { + let tab: StoreTab 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.accentColor.opacity(0.18) : Color(.systemGray6)) - .foregroundStyle(isSelected ? Color.accentColor : Color.primary) - .clipShape(Capsule()) - .overlay( - Capsule() - .stroke(Color.black.opacity(0.04), lineWidth: 1) - ) + HStack(spacing: 6) { + Image(systemName: tab.icon) + .font(.system(size: 12, weight: .medium)) + Text(tab.label) + .font(.subheadline.weight(.semibold)) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(isSelected ? Color.blue.opacity(0.15) : Color(.systemGray6)) + .foregroundStyle(isSelected ? Color.blue : Color.primary) + .clipShape(Capsule()) + .overlay( + Capsule() + .strokeBorder(isSelected ? Color.blue.opacity(0.4) : Color.clear, lineWidth: 1) + ) } .buttonStyle(.plain) } } +// MARK: - Product card + struct ProductCard: View { let imageURL: URL? let title: String let unit: String? let priceText: String - let descriptionText: String? - let itemCountText: String? - let isExpanded: Bool - let onToggle: () -> Void + var storeName: String? = nil // shown in "All" tab so user knows which store let onAdd: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 12) { - 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) - } + 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)) - .onTapGesture(perform: onToggle) - - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.headline) - .lineLimit(2) - .onTapGesture(perform: onToggle) - - if let unit, !unit.isEmpty { - Text(unit) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - .onTapGesture(perform: onToggle) + } + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + .lineLimit(2) + + if let storeName { + HStack(spacing: 4) { + if let asset = storeLogoAsset(for: storeName), + UIImage(named: asset) != nil { + Image(asset) + .resizable() + .scaledToFit() + .frame(height: 50) + } else { + Text(storeName) + .font(.caption.weight(.medium)) + .foregroundStyle(.blue) + } } - - Text(priceText) - .font(.headline) - .onTapGesture(perform: onToggle) } - 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()) + if let unit, !unit.isEmpty { + Text(unit) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) } - .buttonStyle(.plain) + + Text(priceText) + .font(.headline) } - if isExpanded { - VStack(alignment: .leading, spacing: 10) { - AsyncImage(url: imageURL) { phase in - switch phase { - case .empty: - ProgressView() - .frame(maxWidth: .infinity, minHeight: 160) - case .success(let image): - image - .resizable() - .scaledToFill() - .frame(maxWidth: .infinity, minHeight: 160, maxHeight: 200) - .clipped() - case .failure: - Image(systemName: "photo") - .frame(maxWidth: .infinity, minHeight: 160) - .foregroundStyle(.secondary) - @unknown default: - EmptyView() - .frame(maxWidth: .infinity, minHeight: 160) - } - } - .background(Color(.systemGray6)) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .onTapGesture(perform: onToggle) - - Divider() - - if let descriptionText, !descriptionText.isEmpty { - Text(descriptionText) - .font(.subheadline) - .foregroundStyle(.secondary) - } else { - Text("Description coming soon") - .font(.subheadline) - .foregroundStyle(.secondary) - } + Spacer() - if let itemCountText, !itemCountText.isEmpty { - Text("Item count: \(itemCountText)") - .font(.subheadline.weight(.semibold)) - } else { - Text("Item count: —") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.secondary) - } + 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( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(Color(.systemGray6).opacity(0.65)) - ) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(Color.black.opacity(0.04), lineWidth: 1) - ) - .shadow(color: Color.black.opacity(0.06), radius: 8, x: 0, y: 3) + .background(Color(.systemGray6).opacity(0.65)) + .clipShape(RoundedRectangle(cornerRadius: 18)) } } diff --git a/SWFrontUI/ShopwiseFrontEndUI/ShopwiseFrontEndUIApp.swift b/SWFrontUI/ShopwiseFrontEndUI/ShopwiseFrontEndUIApp.swift index 47d233a..82aff1e 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/ShopwiseFrontEndUIApp.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/ShopwiseFrontEndUIApp.swift @@ -5,18 +5,6 @@ // Created by James Chang on 1/13/26. // Edited by Nicholas Castellanos -//import SwiftUI -//import SwiftData -// -//@main -//struct Shopwise: App { -// var body: some Scene { -// WindowGroup { -// ContentView() -// } -// } -//} - import SwiftUI import SwiftData diff --git a/SWFrontUI/ShopwiseFrontEndUI/StoreLogoHelper.swift b/SWFrontUI/ShopwiseFrontEndUI/StoreLogoHelper.swift new file mode 100644 index 0000000..10dce7a --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/StoreLogoHelper.swift @@ -0,0 +1,30 @@ +// StoreLogoHelper.swift +// ShopwiseFrontEndUI +// +// Shared helper — maps store names to Assets.xcassets image names. +// Used by SearchView, MapView, and anywhere else store logos are needed. + +import UIKit + +func storeLogoAsset(for storeName: String) -> String? { + switch storeName { + case "Walmart": + return "logo_walmart" + case "Ralphs", "Ralphs Delivery Now": + return "logo_ralphs" + case "Food4Less", "Food 4 Less": + return "logo_food4less" + case "Stater Bros.", "Stater Bros. Now": + return "logo_stater" + case "Sprouts", "Sprouts Farmers Market", "Sprouts Express": + return "logo_sprouts" + case "ALDI": + return "logo_aldi" + case "Smart & Final", "Smart & Final Extra!": + return "logo_smartfinal" + case "99 Ranch", "99 Ranch Market": + return "logo_99ranch" + default: + return nil + } +}