diff --git a/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.xcworkspace/xcuserdata/jchang.xcuserdatad/UserInterfaceState.xcuserstate b/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.xcworkspace/xcuserdata/jchang.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index e983ceb..0000000 Binary files a/SWFrontUI/ShopwiseFrontEndUI.xcodeproj/project.xcworkspace/xcuserdata/jchang.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/SWFrontUI/ShopwiseFrontEndUI/.gitignore b/SWFrontUI/ShopwiseFrontEndUI/.gitignore new file mode 100644 index 0000000..420d208 --- /dev/null +++ b/SWFrontUI/ShopwiseFrontEndUI/.gitignore @@ -0,0 +1,18 @@ +/WalmartPipeline/walmart_CSVs +/WalmartPipeline/__pycache__ +/.vscode +/WalmartPipeline/TaxonomyBrowsing/taxonomy.json +RecipesDataset.csv +openapi_key.py +.env +/__pycache__ +classified_ingredients.csv +/kroger_output +.DS_Store +node_modules/ + +# Xcode user-specific files +xcuserdata/ +*.xcuserstate +*.xccheckout +*.moved-aside \ No newline at end of file diff --git a/SWFrontUI/ShopwiseFrontEndUI/AppToolbar.swift b/SWFrontUI/ShopwiseFrontEndUI/AppToolbar.swift index 311c036..11b6fd2 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/AppToolbar.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/AppToolbar.swift @@ -5,18 +5,10 @@ struct AppToolbar: ViewModifier { content .toolbar { ToolbarItem(placement: .topBarTrailing) { - HStack(spacing: 14) { - NavigationLink { - SettingsView() - } label: { - Image(systemName: "gearshape") - } - - NavigationLink { - ProfileView() - } label: { - Image(systemName: "person.crop.circle") - } + NavigationLink { + ProfileView() + } label: { + Image(systemName: "person.crop.circle") } } } diff --git a/SWFrontUI/ShopwiseFrontEndUI/AuthManager.swift b/SWFrontUI/ShopwiseFrontEndUI/AuthManager.swift index 3be47b8..0c96f61 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/AuthManager.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/AuthManager.swift @@ -1,51 +1,3 @@ -// -// AuthManager.swift -// ShopwiseFrontEndUI -// -// Created by Nicholas Castellanos on 2/2/26. -// Edited by Nicholas Castellanos on 2/24/26 - -//import SwiftUI -//import Combine -// -//@MainActor -//final class AuthManager: ObservableObject { -// @Published var isSignedIn: Bool = false -// @Published var userEmail: String? = nil -// -// func signIn(email: String, password: String) async throws { -// guard !email.isEmpty, !password.isEmpty else { -// throw AuthError.missingFields -// } -// self.userEmail = email -// self.isSignedIn = true -// } -// -// func signUp(name: String, email: String, password: String) async throws { -// guard !name.isEmpty, !email.isEmpty, !password.isEmpty else { -// throw AuthError.missingFields -// } -// self.userEmail = email -// self.isSignedIn = true -// } -// -// func signOut() { -// userEmail = nil -// isSignedIn = false -// } -// -// enum AuthError: LocalizedError { -// case missingFields -// var errorDescription: String? { "Please fill out all fields." } -// } -//} -// -// -//#Preview { -// AuthView() -// .environmentObject(AuthManager()) -//} - import Foundation import SwiftUI import Combine @@ -69,6 +21,7 @@ final class AuthManager: ObservableObject { @Published var isSignedIn: Bool = false @Published var userEmail: String? = nil @Published var userID: String? = nil + @Published var userName: String? = nil // Tokens private(set) var accessToken: String? = nil @@ -116,6 +69,7 @@ final class AuthManager: ObservableObject { self.applySession(session) self.userEmail = email self.userID = session.user?.id + self.userName = session.user?.user_metadata?.name } } @@ -202,12 +156,14 @@ final class AuthManager: ObservableObject { self.userEmail = user.email self.isSignedIn = true self.userID = user.id + self.userName = user.user_metadata?.name } catch { // token might be expired -> try refresh if let refreshed = try? await refreshSession() { self.userEmail = refreshed.user?.email self.isSignedIn = true self.userID = refreshed.user?.id + self.userName = refreshed.user?.user_metadata?.name } else { clearSession() } @@ -256,6 +212,7 @@ final class AuthManager: ObservableObject { self.accessToken = session.access_token self.refreshToken = session.refresh_token self.userID = session.user?.id + self.userName = session.user?.user_metadata?.name saveTokensToStorage() self.isSignedIn = true } @@ -265,6 +222,7 @@ final class AuthManager: ObservableObject { self.refreshToken = nil self.userEmail = nil self.userID = nil + self.userName = nil self.isSignedIn = false deleteTokensFromStorage() } @@ -365,6 +323,11 @@ struct SupabaseSession: Codable { struct SupabaseUser: Codable { let id: String? let email: String? + let user_metadata: UserMetadata? +} + +struct UserMetadata: Codable { + let name: String? } struct SupabaseError: Codable { @@ -425,7 +388,7 @@ extension AuthManager { } } -//Onboard survey fetch/save +//Onboard survey fetch/save struct UserPreferencesRow: Codable { let user_id: String let diet_preferences: [String] diff --git a/SWFrontUI/ShopwiseFrontEndUI/CartStore.swift b/SWFrontUI/ShopwiseFrontEndUI/CartStore.swift index 267f85d..88516e3 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/CartStore.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/CartStore.swift @@ -8,16 +8,22 @@ struct CartLineItem: Identifiable, Hashable { let unit: String let price: Double var quantity: Int + let groupId: String? + let groupTitle: String? - init(id: String = String(), name: String, unit: String, price: Double, quantity: Int = 1) { - self.id = id - self.name = name - self.unit = unit - self.price = price - self.quantity = quantity + init(id: String = String(),name: String,unit: String,price: Double, quantity: Int = 1,groupId: String? = nil,groupTitle: String? = nil){ + self.id = id + self.name = name + self.unit = unit + self.price = price + self.quantity = quantity + self.groupId = groupId + self.groupTitle = groupTitle } - var lineTotal: Double { price * Double(quantity) } + var lineTotal: Double{ + price * Double(quantity) + } } final class CartStore: ObservableObject { @@ -39,6 +45,10 @@ final class CartStore: ObservableObject { add(id: ingredient.id, name: ingredient.name, unit: ingredient.unit, price: ingredient.price) } + func add(name: String, unit: String, price: Double) { + add(id: name, name: name, unit: unit, price: price) + } + func add(id: String, name: String, unit: String, price: Double) { if let idx = items.firstIndex(where: { $0.id == id }) { items[idx].quantity += 1 @@ -55,6 +65,36 @@ final class CartStore: ObservableObject { } } + func add(recipeId: String, recipeTitle: String, name: String, unit: String, price: Double) { + let id = "\(recipeId)::\(name)" + if let idx = items.firstIndex(where: { $0.id == id }) { + items[idx].quantity += 1 + if items[idx].groupId == nil { + items[idx] = CartLineItem( + id: items[idx].id, + name: items[idx].name, + unit: items[idx].unit, + price: items[idx].price, + quantity: items[idx].quantity, + groupId: recipeId, + groupTitle: recipeTitle + ) + } + } else { + items.append( + CartLineItem( + id: id, + name: name, + unit: unit, + price: price, + quantity: 1, + groupId: recipeId, + groupTitle: recipeTitle + ) + ) + } + } + func increment(_ item: CartLineItem) { guard let idx = items.firstIndex(of: item) else { return } items[idx].quantity += 1 diff --git a/SWFrontUI/ShopwiseFrontEndUI/CartView.swift b/SWFrontUI/ShopwiseFrontEndUI/CartView.swift index f7d5414..92fbd61 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/CartView.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/CartView.swift @@ -3,64 +3,103 @@ import SwiftUI struct CartView: View { @State private var showShoppingList = false @EnvironmentObject private var cartStore: CartStore + @State private var expandedRecipeIds: Set = [] var body: some View { List { - Section("Items") { - if cartStore.items.isEmpty { - Text("Cart is empty") - .foregroundStyle(.secondary) - } else { - ForEach(cartStore.items) { item in - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(item.name) - .font(.headline) - Text(item.unit) - .font(.subheadline) - .foregroundStyle(.secondary) - Text(String(format: "$%.2f", item.price)) - .foregroundStyle(.secondary) - } + if !cartStore.items.isEmpty { + Section("Summary") { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Total Items") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("\(cartStore.itemCount)") + .font(.title3.weight(.semibold)) + } - Spacer() + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text("Total") + .font(.subheadline) + .foregroundStyle(.secondary) + Text(String(format: "$%.2f", cartStore.total)) + .font(.title3.weight(.semibold)) + .monospacedDigit() + } + } + } + } - HStack(spacing: 10) { + if cartStore.items.isEmpty { + Section("Items") { + Text("Cart is empty") + .foregroundStyle(.secondary) + } + } else { + if !recipeGroups.isEmpty { + Section("Recipes") { + ForEach(recipeGroups, id: \.id) { group in + VStack(alignment: .leading, spacing: 10) { Button { - cartStore.decrement(item) + toggle(group.id) } label: { - Image(systemName: "minus.circle") + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(group.title) + .font(.headline) + Text("\(group.items.count) items • \(formatCurrency(groupSubtotal(for: group)))") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: expandedRecipeIds.contains(group.id) ? "chevron.up" : "chevron.down") + .foregroundStyle(.secondary) + } } - .buttonStyle(.borderless) + .buttonStyle(.plain) - Text("\(item.quantity)") - .font(.headline) - .frame(minWidth: 24) - - Button { - cartStore.increment(item) - } label: { - Image(systemName: "plus.circle") + if expandedRecipeIds.contains(group.id) { + Divider() + ForEach(Array(group.items.enumerated()), id: \.element.id) { index, item in + itemRow(item) + if index != group.items.count - 1 { + Divider() + } + } } - .buttonStyle(.borderless) } - } - .padding(.vertical, 4) - } - .onDelete { indexSet in - for idx in indexSet { - cartStore.remove(cartStore.items[idx]) + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(.secondarySystemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.black.opacity(0.04), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.06), radius: 8, x: 0, y: 3) + .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } } } - } - Section("Summary") { - HStack { - Text("Total") - Spacer() - Text(String(format: "$%.2f", cartStore.total)) - .foregroundStyle(.secondary) + Section("Individual Items") { + if individualItems.isEmpty { + Text("No individual items") + .foregroundStyle(.secondary) + } else { + ForEach(individualItems) { item in + itemRow(item) + } + .onDelete { indexSet in + for idx in indexSet { + cartStore.remove(individualItems[idx]) + } + } + } } } @@ -73,6 +112,16 @@ struct CartView: View { } .disabled(cartStore.items.isEmpty) } + + if !cartStore.items.isEmpty { + Section { + Button(role: .destructive) { + cartStore.clear() + } label: { + Text("Clear Cart") + } + } + } } .navigationTitle("Cart") .appToolbar() @@ -80,4 +129,91 @@ struct CartView: View { ShoppingListView() } } + + private var recipeGroups: [RecipeGroup] { + let items = cartStore.items + var groups: [String: RecipeGroup] = [:] + + for item in items { + guard let groupId = item.groupId, !groupId.isEmpty else { continue } + let title = item.groupTitle ?? "Recipe" + + if let existing = groups[groupId] { + var newItems = existing.items + newItems.append(item) + groups[groupId] = RecipeGroup(id: existing.id, title: existing.title, items: newItems) + } else { + groups[groupId] = RecipeGroup(id: groupId, title: title, items: [item]) + } + } + + return groups.values + .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + } + + private var individualItems: [CartLineItem] { + cartStore.items.filter { $0.groupId == nil } + } + + private func toggle(_ id: String) { + if expandedRecipeIds.contains(id) { + expandedRecipeIds.remove(id) + } else { + expandedRecipeIds.insert(id) + } + } + + @ViewBuilder + private func itemRow(_ item: CartLineItem) -> some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(item.name) + .font(.headline) + if !item.unit.isEmpty { + Text(item.unit) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Text(String(format: "$%.2f", item.price)) + .foregroundStyle(.secondary) + } + + Spacer() + + HStack(spacing: 10) { + Button { + cartStore.decrement(item) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + + Text("\(item.quantity)") + .font(.headline) + .frame(minWidth: 24) + + Button { + cartStore.increment(item) + } label: { + Image(systemName: "plus.circle") + } + .buttonStyle(.borderless) + } + } + .padding(.vertical, 4) + } + + private func groupSubtotal(for group: RecipeGroup) -> Double { + group.items.reduce(0) { $0 + ($1.price * Double($1.quantity)) } + } + + private func formatCurrency(_ value: Double) -> String { + String(format: "$%.2f", value) + } +} + +private struct RecipeGroup: Identifiable { + let id: String + let title: String + let items: [CartLineItem] } diff --git a/SWFrontUI/ShopwiseFrontEndUI/OnboardingSurveyView.swift b/SWFrontUI/ShopwiseFrontEndUI/OnboardingSurveyView.swift index ef884db..3204c3a 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/OnboardingSurveyView.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/OnboardingSurveyView.swift @@ -17,33 +17,49 @@ struct OnboardingSurveyView: View { let allergyOptions = [ "Peanuts", "Tree Nuts", "Dairy", "Eggs", - "Soy", "Wheat", "Shellfish", "Fish", "Sesame" + "Soy", "Wheat", "Shellfish", "Fish" ] var onComplete: (() -> Void)? = nil var body: some View { List { - Section("Diet Preferences") { - ForEach(dietOptions, id: \.self) { option in - PreferenceRow( - title: option, - isSelected: selectedDiets.contains(option) - ) { - toggle(option, in: &selectedDiets) + Section { + PreferenceChipGrid( + options: dietOptions, + selected: selectedDiets, + onToggle: { toggle($0, in: &selectedDiets) } + ) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + + if !selectedDiets.isEmpty { + Button("Clear Diet Preferences") { + selectedDiets.removeAll() } } + } header: { + Text("Diet Preferences") + } footer: { + Text("Pick any that apply. You can update these anytime.") } - Section("Allergies") { - ForEach(allergyOptions, id: \.self) { option in - PreferenceRow( - title: option, - isSelected: selectedAllergies.contains(option) - ) { - toggle(option, in: &selectedAllergies) + Section { + PreferenceChipGrid( + options: allergyOptions, + selected: selectedAllergies, + onToggle: { toggle($0, in: &selectedAllergies) } + ) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + + if !selectedAllergies.isEmpty { + Button("Clear Allergies") { + selectedAllergies.removeAll() } } + } header: { + Text("Allergies") + } footer: { + Text("We’ll filter recipes and suggestions based on these.") } if let errorMessage { @@ -120,20 +136,34 @@ struct OnboardingSurveyView: View { } } -struct PreferenceRow: View { - let title: String - let isSelected: Bool - let action: () -> Void +private struct PreferenceChipGrid: View { + let options: [String] + let selected: Set + let onToggle: (String) -> Void + + private let columns = [ + GridItem(.adaptive(minimum: 110), spacing: 10) + ] var body: some View { - Button(action: action) { - HStack { - Text(title) - Spacer() - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .foregroundStyle(isSelected ? .blue : .secondary) + LazyVGrid(columns: columns, spacing: 10) { + ForEach(options, id: \.self) { option in + let isSelected = selected.contains(option) + Button { + onToggle(option) + } label: { + Text(option) + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(isSelected ? Color.blue.opacity(0.18) : Color(.systemGray6)) + .foregroundStyle(isSelected ? Color.blue : Color.primary) + .clipShape(Capsule()) + } + .buttonStyle(.plain) } } - .buttonStyle(.plain) + .padding(.vertical, 2) } } diff --git a/SWFrontUI/ShopwiseFrontEndUI/ProfileView.swift b/SWFrontUI/ShopwiseFrontEndUI/ProfileView.swift index ab5aa69..80b9d3e 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/ProfileView.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/ProfileView.swift @@ -1,7 +1,10 @@ import SwiftUI +import UIKit struct ProfileView: View { @EnvironmentObject var auth: AuthManager + @EnvironmentObject private var cartStore: CartStore + @Environment(\.openURL) private var openURL var body: some View { List { @@ -12,10 +15,10 @@ struct ProfileView: View { .foregroundStyle(.blue) VStack(alignment: .leading) { - Text(auth.userEmail ?? "No email") + Text(greetingText) .font(.headline) - Text("Signed in") + Text(auth.userEmail ?? "No email") .font(.caption) .foregroundStyle(.secondary) } @@ -31,6 +34,21 @@ struct ProfileView: View { } } + Section("Settings") { + Button("Location Services") { + openAppSettings() + } + } + + Section("App Info") { + HStack { + Text("Version") + Spacer() + Text(appVersion) + .foregroundStyle(.secondary) + } + } + Section { Button(role: .destructive) { Task { await auth.signOut() } @@ -39,6 +57,28 @@ struct ProfileView: View { } } } - .navigationTitle("Profile") + .navigationTitle("Account & Settings") + } + + 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))" + } + return version ?? "1.0" + } + + private var greetingText: String { + if let name = auth.userName, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return name + } + return "Hello" + } + + private func openAppSettings() { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } } } diff --git a/SWFrontUI/ShopwiseFrontEndUI/Recipe.swift b/SWFrontUI/ShopwiseFrontEndUI/Recipe.swift index ba98dd4..0437b20 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/Recipe.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/Recipe.swift @@ -1,337 +1,112 @@ -// -// RecipeView.swift -// ShopwiseFrontEndUI -// -// Created by Nicholas Castellanos on 3/11/26. -// +import Foundation +import NaturalLanguage + +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" + } -import SwiftUI + var ingredientList: [String] { + guard let ingredients, !ingredients.isEmpty else { return [] } -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 + let trimmed = ingredients.trimmingCharacters(in: .whitespacesAndNewlines) + + if let singleQuoted = extractQuotedItems(from: trimmed, quote: "'"), !singleQuoted.isEmpty { + return singleQuoted } - .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) - } + if let doubleQuoted = extractQuotedItems(from: trimmed, quote: "\""), !doubleQuoted.isEmpty { + return doubleQuoted } + + let fallback = trimmed + .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + .components(separatedBy: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return fallback } - - @ViewBuilder - private var content: some View { - if isLoading { - HStack { - Spacer() - ProgressView("Loading…") - Spacer() - } - .listRowSeparator(.hidden) + private func extractQuotedItems(from text: String, quote: String) -> [String]? { + let pattern = "\(quote)([^\\\(quote)]*)\(quote)" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } - } else if let errorText { - Text(errorText) - .foregroundStyle(.red) + let range = NSRange(text.startIndex.. String? in + guard match.numberOfRanges > 1, + let r = Range(match.range(at: 1), in: text) else { return nil } + let value = String(text[r]).trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value } - } - - 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) - } - } + return items.isEmpty ? nil : items } - - 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) + var instructionSteps: [String] { + guard let instructions, !instructions.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return [] + } - Text("\(recipe.difficultyText) • \(recipe.estimatedMinutes) min") - .font(.subheadline) - .foregroundStyle(.secondary) - } + let normalized = instructions + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") - Spacer() + let paragraphs = normalized + .components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } - Image(systemName: expandedID == recipe.id ? "chevron.up" : "chevron.down") - .foregroundStyle(.secondary) - } + if paragraphs.count >= 2 { + return paragraphs } - } - - 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) + if #available(iOS 12.0, *) { + let tokenizer = NLTokenizer(unit: .sentence) + tokenizer.string = normalized + var steps: [String] = [] + tokenizer.enumerateTokens(in: normalized.startIndex..? = 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 = [] + + private var filtered: [RecipeRow] { + 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) + } + } + } + + @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: 14) { + + 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: 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) + ) + } + + VStack(alignment: .leading, spacing: 6) { + 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) + } + + if let instructions = recipe.instructions, + !instructions.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Divider() + .padding(.vertical, 2) + + Button { + withAnimation(.snappy){ + toggleInstructions(recipe.id) + } + } label: { + HStack { + Text("Instructions") + .font(.headline) + Spacer() + Image(systemName: expandedInstructionIds.contains(recipe.id) ? "chevron.up" : "chevron.down") + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + + if expandedInstructionIds.contains(recipe.id) { + if !recipe.instructionSteps.isEmpty { + ForEach(Array(recipe.instructionSteps.enumerated()), id: \.offset) { index, step in + Text("Step \(index + 1): \(step)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } else { + 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 + ) + } + } label: { + let excluded = excludedIngredientsByRecipe[recipe.id] ?? [] + let selectedCount = max(0, recipe.ingredientList.count - excluded.count) + VStack(spacing: 4) { + Text("\(selectedCount) selected") + .font(.footnote) + .foregroundStyle(.secondary) + Label("Add Selected Ingredients", systemImage: "cart.badge.plus") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + } + } + + private func toggleIngredient(recipeId: Int, item: String) { + var set = excludedIngredientsByRecipe[recipeId] ?? [] + 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) + } else { + 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: { + HStack(spacing: 10) { + Image(systemName: isExcluded ? "minus.circle" : "checkmark.circle.fill") + .foregroundStyle(isExcluded ? .secondary : Color.green) + Text(item) + .foregroundStyle(isExcluded ? .secondary : .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 + } 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/SearchView.swift b/SWFrontUI/ShopwiseFrontEndUI/SearchView.swift index 4529793..0916478 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/SearchView.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/SearchView.swift @@ -9,6 +9,7 @@ struct SearchView: View { @State private var results: [WalmartItem] = [] @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 @@ -26,6 +27,7 @@ struct SearchView: View { Text("ShopWise") .font(.system(size: 34, weight: .bold)) .padding(.top, 6) + .padding(.bottom, 4) // Search bar (custom like your right screenshot) HStack(spacing: 10) { @@ -54,6 +56,7 @@ struct SearchView: View { .padding(.vertical, 12) .background(Color(.systemGray6)) .clipShape(RoundedRectangle(cornerRadius: 18)) + .padding(.bottom, 4) // Category chips row ScrollView(.horizontal, showsIndicators: false) { @@ -88,6 +91,18 @@ struct SearchView: View { 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 + } + } + }, onAdd: { cartStore.add(id: String(item.id), name: item.name, unit: unitText(for: item) ?? "", price: item.retail_price ?? 0) } @@ -227,9 +242,13 @@ struct SearchCategoryChip: View { .font(.subheadline.weight(.semibold)) .padding(.horizontal, 14) .padding(.vertical, 8) - .background(isSelected ? Color.blue.opacity(0.18) : Color(.systemGray6)) - .foregroundStyle(isSelected ? Color.blue : Color.primary) + .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) + ) } .buttonStyle(.plain) } @@ -240,67 +259,133 @@ struct ProductCard: View { let title: String let unit: String? let priceText: String + let descriptionText: String? + let itemCountText: String? + let isExpanded: Bool + let onToggle: () -> Void let onAdd: () -> Void var body: some View { - HStack(spacing: 14) { - AsyncImage(url: imageURL) { phase in - switch phase { - case .empty: - ProgressView() - .frame(width: 74, height: 74) - case .success(let image): - image - .resizable() - .scaledToFill() - .frame(width: 74, height: 74) - .clipped() - case .failure: - Image(systemName: "photo") - .frame(width: 74, height: 74) - .foregroundStyle(.secondary) - @unknown default: - EmptyView() - .frame(width: 74, height: 74) + 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) + } } - } - .background(Color(.systemGray6)) - .clipShape(RoundedRectangle(cornerRadius: 16)) - - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.headline) - .lineLimit(2) + .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) + } - if let unit, !unit.isEmpty { - Text(unit) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) + Text(priceText) + .font(.headline) + .onTapGesture(perform: onToggle) } - Text(priceText) - .font(.headline) + Spacer() + + Button(action: onAdd) { + HStack(spacing: 8) { + Image(systemName: "cart.badge.plus") + Text("Add") + } + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .foregroundStyle(.white) + .background(Color.blue) + .clipShape(Capsule()) + } + .buttonStyle(.plain) } - Spacer() + 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) + } - Button(action: onAdd) { - HStack(spacing: 8) { - Image(systemName: "cart.badge.plus") - Text("Add") + if let itemCountText, !itemCountText.isEmpty { + Text("Item count: \(itemCountText)") + .font(.subheadline.weight(.semibold)) + } else { + Text("Item count: —") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + } } - .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .foregroundStyle(.white) - .background(Color.blue) - .clipShape(Capsule()) } - .buttonStyle(.plain) } .padding(14) - .background(Color(.systemGray6).opacity(0.65)) - .clipShape(RoundedRectangle(cornerRadius: 18)) + .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) } } diff --git a/SWFrontUI/ShopwiseFrontEndUI/SettingsView.swift b/SWFrontUI/ShopwiseFrontEndUI/SettingsView.swift index ab559b8..6656f1a 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/SettingsView.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/SettingsView.swift @@ -1,16 +1,19 @@ -import SwiftUI +/*import SwiftUI +import UIKit struct SettingsView: View { - @AppStorage("notificationsEnabled") private var notificationsEnabled = true - @AppStorage("locationEnabled") private var locationEnabled = true - @AppStorage("darkModeEnabled") private var darkModeEnabled = false + @Environment(\.openURL) private var openURL var body: some View { List { Section("Preferences") { - Toggle("Enable Notifications", isOn: $notificationsEnabled) - Toggle("Use Location Services", isOn: $locationEnabled) - Toggle("Dark Mode", isOn: $darkModeEnabled) + Button("Notification Settings") { + openAppSettings() + } + + Button("Location Services") { + openAppSettings() + } } Section("Support") { @@ -34,4 +37,10 @@ struct SettingsView: View { } .navigationTitle("Settings") } -} + + private func openAppSettings() { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } + } +}*/ diff --git a/SWFrontUI/ShopwiseFrontEndUI/ShoppingListView.swift b/SWFrontUI/ShopwiseFrontEndUI/ShoppingListView.swift index 24421e7..eb2e409 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/ShoppingListView.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/ShoppingListView.swift @@ -5,6 +5,7 @@ struct ShoppingListView: View { @Environment(\.dismiss) private var dismiss @State private var checkedIDs: Set = [] + @State private var expandedRecipeIds: Set = [] private var checkedTotal: Double { cartStore.items @@ -18,35 +19,51 @@ struct ShoppingListView: View { var body: some View { List { - Section("Shopping List") { - if cartStore.items.isEmpty { + if cartStore.items.isEmpty { + Section("Shopping List") { Text("No items to shop for.") .foregroundStyle(.secondary) - } else { - ForEach(cartStore.items) { item in - Button { - toggle(item.id) - } label: { - HStack(spacing: 12) { - Image(systemName: checkedIDs.contains(item.id) ? "checkmark.circle.fill" : "circle") - .imageScale(.large) - - VStack(alignment: .leading, spacing: 2) { - Text(item.name) - .strikethrough(checkedIDs.contains(item.id)) - Text("\(item.quantity) × \(item.unit)") - .font(.subheadline) - .foregroundStyle(.secondary) + } + } else { + if !recipeGroups.isEmpty { + Section("Recipes") { + ForEach(recipeGroups) { group in + VStack(alignment: .leading, spacing: 8) { + Button { + toggleRecipeGroup(group.id) + } label: { + HStack { + Text(group.title) + .font(.headline) + Spacer() + Text("\(group.items.count) items") + .font(.subheadline) + .foregroundStyle(.secondary) + Image(systemName: expandedRecipeIds.contains(group.id) ? "chevron.up" : "chevron.down") + .foregroundStyle(.secondary) + } } + .buttonStyle(.plain) - Spacer() - - Text(String(format: "$%.2f", item.price * Double(item.quantity))) - .foregroundStyle(.secondary) - .monospacedDigit() + if expandedRecipeIds.contains(group.id) { + ForEach(group.items) { item in + itemRow(item) + } + } } + .padding(.vertical, 4) + } + } + } + + Section("Individual Items") { + if individualItems.isEmpty { + Text("No individual items") + .foregroundStyle(.secondary) + } else { + ForEach(individualItems) { item in + itemRow(item) } - .buttonStyle(.plain) } } } @@ -120,4 +137,76 @@ struct ShoppingListView: View { checkedIDs.insert(id) } } + + private func toggleRecipeGroup(_ id: String) { + if expandedRecipeIds.contains(id) { + expandedRecipeIds.remove(id) + } else { + expandedRecipeIds.insert(id) + } + } + + private var recipeGroups: [RecipeGroup] { + let items = cartStore.items + var groups: [String: RecipeGroup] = [:] + + for item in items { + guard let groupId = item.groupId, !groupId.isEmpty else { continue } + let title = item.groupTitle ?? "Recipe" + + if let existing = groups[groupId] { + var newItems = existing.items + newItems.append(item) + groups[groupId] = RecipeGroup(id: existing.id, title: existing.title, items: newItems) + } else { + groups[groupId] = RecipeGroup(id: groupId, title: title, items: [item]) + } + } + + return groups.values + .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + } + + private var individualItems: [CartLineItem] { + cartStore.items.filter { $0.groupId == nil } + } + + @ViewBuilder + private func itemRow(_ item: CartLineItem) -> some View { + Button { + toggle(item.id) + } label: { + HStack(spacing: 12) { + Image(systemName: checkedIDs.contains(item.id) ? "checkmark.circle.fill" : "circle") + .imageScale(.large) + + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + .strikethrough(checkedIDs.contains(item.id)) + if !item.unit.isEmpty { + Text("\(item.quantity) × \(item.unit)") + .font(.subheadline) + .foregroundStyle(.secondary) + } else { + Text("\(item.quantity)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Text(String(format: "$%.2f", item.price * Double(item.quantity))) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + .buttonStyle(.plain) + } +} + +private struct RecipeGroup: Identifiable { + let id: String + let title: String + let items: [CartLineItem] } diff --git a/SWFrontUI/ShopwiseFrontEndUI/UIModules.swift b/SWFrontUI/ShopwiseFrontEndUI/UIModules.swift index 5181aca..0d08d35 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/UIModules.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/UIModules.swift @@ -24,9 +24,16 @@ struct CardContainer: View { var body: some View { content - .padding(12) - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .padding(14) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color(.secondarySystemBackground)) + ) + .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) } } @@ -74,4 +81,3 @@ struct ItemCardView: View { } } } - diff --git a/SWFrontUI/ShopwiseFrontEndUI/WalmartItems.swift b/SWFrontUI/ShopwiseFrontEndUI/WalmartItems.swift index 44b2e4c..c6ea2b9 100644 --- a/SWFrontUI/ShopwiseFrontEndUI/WalmartItems.swift +++ b/SWFrontUI/ShopwiseFrontEndUI/WalmartItems.swift @@ -17,4 +17,5 @@ struct WalmartItem: Identifiable, Decodable { let mediumImage: String? let largeImage: String? let color: String? + let descriptionText: String? }