diff --git a/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.xcworkspace/xcuserdata/nicholascastellanos.xcuserdatad/UserInterfaceState.xcuserstate b/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.xcworkspace/xcuserdata/nicholascastellanos.xcuserdatad/UserInterfaceState.xcuserstate index 6c0fcd1..d183f9d 100644 Binary files a/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.xcworkspace/xcuserdata/nicholascastellanos.xcuserdatad/UserInterfaceState.xcuserstate and b/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.xcworkspace/xcuserdata/nicholascastellanos.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/SWFrontUI/ShopwiseFrontEndUI/AuthManager.swift b/SWFrontUI/ShopwiseFrontEndUI/AuthManager.swift index 9353e75..3be47b8 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/AuthManager.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/AuthManager.swift @@ -506,3 +506,38 @@ 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( + name: "select", + value: "id,Title,Ingredients,Instructions,Image_Name,Cleaned_Ingredients,image_url" + ), + URLQueryItem(name: "limit", value: "\(limit)"), + 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) + } +} diff --git a/SWFrontUI/ShopwiseFrontEndUI/Recipe.swift b/SWFrontUI/ShopwiseFrontEndUI/Recipe.swift index 320d317..ba98dd4 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/Recipe.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/Recipe.swift @@ -1,29 +1,337 @@ -import Foundation - -struct Recipe: Identifiable, Codable, Hashable { - let id: String - let name: String - let difficulty: String - let minutes: Int - let imageURL: String? - let imageName: String? - let ingredients: [Ingredient] - - init( - id: String, - name: String, - difficulty: String, - minutes: Int, - imageURL: String? = nil, - imageName: String? = nil, - ingredients: [Ingredient] - ) { - self.id = id - self.name = name - self.difficulty = difficulty - self.minutes = minutes - self.imageURL = imageURL - self.imageName = imageName - self.ingredients = ingredients +// +// RecipeView.swift +// ShopwiseFrontEndUI +// +// Created by Nicholas Castellanos on 3/11/26. +// + +import SwiftUI + +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 + + private var filtered: [RecipeRow] { + if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return recipes + } + return recipes.filter { $0.title.localizedCaseInsensitiveContains(query) } + } + +// var body: some View { +// List { +// if isLoading { +// HStack { +// Spacer() +// ProgressView("Loading…") +// Spacer() +// } +// .listRowSeparator(.hidden) +// } else if let errorText { +// Text(errorText) +// .foregroundStyle(.red) +// } else { +// ForEach(Array(filtered.enumerated()), id: \.element.id) { index, recipe in +// Section { +// Button { +// withAnimation(.snappy) { +// expandedID = (expandedID == recipe.id) ? nil : recipe.id +// } +// } label: { +// CardContainer { +// HStack(spacing: 12) { +// 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") +// .foregroundStyle(.secondary) +// default: +// ProgressView() +// } +// } +// .frame(width: 54, height: 54) +// .background(Color(.systemGray5)) +// .clipShape(RoundedRectangle(cornerRadius: 12)) +// } else { +// Circle() +// .fill(Color(.systemGray5)) +// .frame(width: 54, height: 54) +// .overlay( +// Image(systemName: "fork.knife") +// .foregroundStyle(.secondary) +// ) +// } +// +// VStack(alignment: .leading, spacing: 4) { +// Text(recipe.title) +// .font(.headline) +// .lineLimit(2) +// +// Text("\(recipe.difficultyText) • \(recipe.estimatedMinutes) min") +// .font(.subheadline) +// .foregroundStyle(.secondary) +// } +// +// Spacer() +// +// Image(systemName: expandedID == recipe.id ? "chevron.up" : "chevron.down") +// .foregroundStyle(.secondary) +// } +// } +// } +// .buttonStyle(.plain) +// .onAppear { +// if index == filtered.count - 1 { +// Task { +// await loadRecipes(reset: false) +// } +// } +// } +// +// if expandedID == recipe.id { +// CardContainer { +// Text("Ingredients") +// .font(.headline) +// +// ForEach(recipe.ingredientList, id: \.self) { item in +// HStack(spacing: 10) { +// Image(systemName: "checkmark.circle") +// .foregroundStyle(.secondary) +// Text(item) +// } +// .padding(.vertical, 2) +// } +// +// Button { +// for item in recipe.ingredientList { +// cartStore.add(name: item, unit: "", price: 0) +// } +// } label: { +// Label("Add Ingredients to Cart", systemImage: "cart.badge.plus") +// .frame(maxWidth: .infinity) +// .foregroundStyle(.white) +// } +// .buttonStyle(.borderedProminent) +// } +// .transition(.opacity.combined(with: .move(edge: .top))) +// } +// } +// } +// } +// } +// .listStyle(.plain) +// .navigationTitle("ShopWise") +// .searchable(text: $query, prompt: "Search recipes…") +// .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) +// } +// } +// } + + 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) + } + } + } + + @ViewBuilder + private var content: some View { + + if isLoading { + HStack { + Spacer() + ProgressView("Loading…") + Spacer() + } + .listRowSeparator(.hidden) + + } else if let errorText { + 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 + } + } label: { + recipeCard(recipe) + } + .buttonStyle(.plain) + .onAppear { + if index == filtered.count - 1 { + Task { + await loadRecipes(reset: false) + } + } + } + + if expandedID == recipe.id { + recipeExpanded(recipe) + } + } + } + + private func recipeCard(_ recipe: RecipeRow) -> some View { + CardContainer { + HStack(spacing: 12) { + + 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() + } + } + .frame(width: 54, height: 54) + .background(Color(.systemGray5)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + } else { + Circle() + .fill(Color(.systemGray5)) + .frame(width: 54, height: 54) + } + + VStack(alignment: .leading, spacing: 4) { + Text(recipe.title) + .font(.headline) + .lineLimit(2) + + Text("\(recipe.difficultyText) • \(recipe.estimatedMinutes) min") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: expandedID == recipe.id ? "chevron.up" : "chevron.down") + .foregroundStyle(.secondary) + } + } + } + + private func recipeExpanded(_ recipe: RecipeRow) -> some View { + CardContainer { + + Text("Ingredients") + .font(.headline) + + ForEach(recipe.ingredientList, id: \.self) { item in + HStack { + Image(systemName: "checkmark.circle") + Text(item) + } + } + + Button { + for item in recipe.ingredientList { + cartStore.add(id: String(recipe.id), name: item, unit: "", price: 0) + } + } label: { + Label("Add Ingredients to Cart", systemImage: "cart.badge.plus") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + } + + @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 + } else { + if !hasMore { return } + isLoadingMore = true + } + + errorText = nil + + do { + let fetched = try await auth.fetchRecipes( + search: trimmed.isEmpty ? nil : trimmed, + limit: pageSize, + offset: offset + ) + + if reset { + recipes = fetched + } else { + recipes.append(contentsOf: fetched) + } + + offset += fetched.count + if fetched.count < pageSize { + hasMore = false + } + } catch { + errorText = (error as NSError).localizedDescription + } + + isLoading = false + isLoadingMore = false } } diff --git a/SWFrontUI/ShopwiseFrontEndUI/RecipeView.swift b/SWFrontUI/ShopwiseFrontEndUI/RecipeView.swift index 2b2485a..63725ae 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/RecipeView.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/RecipeView.swift @@ -1,183 +1,56 @@ -import SwiftUI - -struct RecipeView: View { - @State private var query = "" - @State private var expandedID: String? = nil - @State private var excludedByRecipe: [String: Set] = [:] - - @EnvironmentObject private var cartStore: CartStore - - private let recipes: [Recipe] = [ - Recipe( - id: "recipe_spaghetti", - name: "Spaghetti", - difficulty: "Easy", - minutes: 25, - imageURL: nil, - imageName: "spaghetti", - ingredients: [ - Ingredient(id: "ing_spaghetti", name: "Spaghetti pasta", unit: "1 lb", price: 2.49, imageURL: nil, imageName: nil), - Ingredient(id: "ing_marinara", name: "Marinara sauce", unit: "24 oz", price: 3.99, imageURL: nil, imageName: nil), - Ingredient(id: "ing_garlic", name: "Garlic", unit: "3 ct", price: 1.29, imageURL: nil, imageName: nil), - Ingredient(id: "ing_onion", name: "Onion", unit: "1 ct", price: 0.99, imageURL: nil, imageName: nil), - Ingredient(id: "ing_olive_oil", name: "Olive oil", unit: "16.9 oz", price: 6.49, imageURL: nil, imageName: nil), - Ingredient(id: "ing_parmesan", name: "Parmesan", unit: "6 oz", price: 4.49, imageURL: nil, imageName: nil) - ] - ), - Recipe( - id: "recipe_orange_chicken", - name: "Orange Chicken", - difficulty: "Medium", - minutes: 35, - imageURL: nil, - imageName: "orangechicken", - ingredients: [ - Ingredient(id: "ing_chicken_breast", name: "Chicken breast", unit: "1 lb", price: 5.99, imageURL: nil, imageName: nil), - Ingredient(id: "ing_cornstarch", name: "Cornstarch", unit: "16 oz", price: 1.99, imageURL: nil, imageName: nil), - Ingredient(id: "ing_orange_juice", name: "Orange juice", unit: "12 oz", price: 3.49, imageURL: nil, imageName: nil), - Ingredient(id: "ing_soy_sauce", name: "Soy sauce", unit: "10 oz", price: 2.29, imageURL: nil, imageName: nil), - Ingredient(id: "ing_honey", name: "Honey", unit: "12 oz", price: 4.79, imageURL: nil, imageName: nil), - Ingredient(id: "ing_garlic", name: "Garlic", unit: "3 ct", price: 1.29, imageURL: nil, imageName: nil), - Ingredient(id: "ing_ginger", name: "Ginger", unit: "3 oz", price: 1.49, imageURL: nil, imageName: nil), - Ingredient(id: "ing_rice", name: "Rice", unit: "2 lb", price: 3.79, imageURL: nil, imageName: nil) - ] - ) - ] - - private var filtered: [Recipe] { - if query.isEmpty { return recipes } - return recipes.filter { $0.name.localizedCaseInsensitiveContains(query) } +// +// RecipeModels.swift +// ShopwiseFrontEndUI +// +// Created by Nicholas Castellanos on 3/11/26. +// + +import Foundation + +struct RecipeRow: Identifiable, Codable, Hashable { + let id: Int + let title: String + let ingredients: String? + let instructions: String? + let imageName: String? + let cleanedIngredients: String? + let imageURL: String? + + enum CodingKeys: String, CodingKey { + case id + case title = "Title" + case ingredients = "Ingredients" + case instructions = "Instructions" + case imageName = "Image_Name" + case cleanedIngredients = "Cleaned_Ingredients" + case imageURL = "image_url" } - var body: some View { - List { - ForEach(filtered) { recipe in - Section { - Button { - withAnimation(.snappy) { - expandedID = (expandedID == recipe.id) ? nil : recipe.id - } - } label: { - CardContainer { - HStack(spacing: 12) { - if let imageName = recipe.imageName { - Image(imageName) - .resizable() - .scaledToFill() - .frame(width: 54, height: 54) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } else { - Circle() - .fill(Color(.systemGray5)) - .frame(width: 54, height: 54) - .overlay( - Image(systemName: "fork.knife") - .foregroundStyle(.secondary) - ) - } - - VStack(alignment: .leading, spacing: 4) { - Text(recipe.name) - .font(.headline) - Text("\(recipe.difficulty) • \(recipe.minutes) min") - .font(.subheadline) - .foregroundStyle(.secondary) - } - - Spacer() - - Image(systemName: expandedID == recipe.id ? "chevron.up" : "chevron.down") - .foregroundStyle(.secondary) - } - } - } - .buttonStyle(.plain) - - if expandedID == recipe.id { - CardContainer { - if let imageName = recipe.imageName { - Image(imageName) - .resizable() - .scaledToFill() - .frame(height: 160) - .clipped() - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - } + var ingredientList: [String] { + guard let ingredients, !ingredients.isEmpty else { return [] } - Text("Ingredients") - .font(.headline) + let trimmed = ingredients + .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) - ForEach(recipe.ingredients) { ingredient in - let isExcluded = excludedByRecipe[recipe.id, default: []].contains(ingredient.id) - - Button { - if isExcluded { - excludedByRecipe[recipe.id, default: []].remove(ingredient.id) - } else { - excludedByRecipe[recipe.id, default: []].insert(ingredient.id) - } - } label: { - HStack(spacing: 10) { - Image(systemName: isExcluded ? "square" : "checkmark.square.fill") - .imageScale(.large) - - VStack(alignment: .leading, spacing: 2) { - Text(ingredient.name) - .strikethrough(isExcluded) - Text(ingredient.unit) - .font(.subheadline) - .foregroundStyle(.secondary) - } - - Spacer() - - Text(String(format: "$%.2f", ingredient.price)) - .foregroundStyle(.secondary) - .monospacedDigit() - } - .padding(.vertical, 2) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - - Button { - addIngredientsToCart(recipe) - } label: { - Label("Add Ingredients to Cart", systemImage: "cart.badge.plus") - .frame(maxWidth: .infinity) - .foregroundStyle(.white) - } - .buttonStyle(.borderedProminent) - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - } - } - .listStyle(.plain) - .navigationTitle("ShopWise") - .searchable(text: $query, prompt: "Search recipes…") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - HStack(spacing: 14) { - NavigationLink { SettingsView() } label: { Image(systemName: "gearshape") } - NavigationLink { ProfileView() } label: { Image(systemName: "person.crop.circle") } - } + return trimmed + .components(separatedBy: "',") + .map { + $0.replacingOccurrences(of: "'", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) } - } + .filter { !$0.isEmpty } } - private func addIngredientsToCart(_ recipe: Recipe) { - let excluded = excludedByRecipe[recipe.id, default: []] + var difficultyText: String { + let count = ingredientList.count + if count <= 5 { return "Easy" } + if count <= 10 { return "Medium" } + return "Hard" + } - for ingredient in recipe.ingredients where !excluded.contains(ingredient.id) { - cartStore.add( - id: ingredient.id, - name: ingredient.name, - unit: ingredient.unit, - price: ingredient.price - ) - } + var estimatedMinutes: Int { + let text = instructions ?? "" + let sentences = text.components(separatedBy: ".").filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }.count + return max(10, min(60, sentences * 5)) } }