diff --git a/App/AppState 2.swift b/App/AppState 2.swift deleted file mode 100644 index d1a6932..0000000 --- a/App/AppState 2.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// AppState 2.swift -// MealEngine -// -// Created by Guest User on 4/17/26. -// - - -import SwiftUI - -final class AppState: ObservableObject { - - let plannerVM: MealPlannerViewModel - let goalsVM: GoalsViewModel - let historyVM: HistoryViewModel - - init() { - self.plannerVM = MealPlannerViewModel() - self.goalsVM = GoalsViewModel() - self.historyVM = HistoryViewModel() - } -} \ No newline at end of file diff --git a/App/AppState.swift b/App/AppState.swift index 16ac149..419ba52 100644 --- a/App/AppState.swift +++ b/App/AppState.swift @@ -1,12 +1,5 @@ -/* import SwiftUI - -final class AppState: ObservableObject { - @Published var plannerVM = MealPlannerViewModel() - @Published var goalsVM = GoalsViewModel() - @Published var historyVM = HistoryViewModel() -} */ - import SwiftUI +import Combine final class AppState: ObservableObject { @@ -14,9 +7,20 @@ final class AppState: ObservableObject { let goalsVM: GoalsViewModel let historyVM: HistoryViewModel + private var cancellables = Set() + init() { self.plannerVM = MealPlannerViewModel() - self.goalsVM = GoalsViewModel() + self.goalsVM = GoalsViewModel() self.historyVM = HistoryViewModel() + + // Keep GoalsViewModel in sync whenever plannerVM publishes any change. + plannerVM.objectWillChange + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + self.goalsVM.sync(from: self.plannerVM) + } + .store(in: &cancellables) } } diff --git a/ViewModels/Goals/GoalsViewModel.swift b/ViewModels/Goals/GoalsViewModel.swift index b9a9ceb..6b4cf21 100644 --- a/ViewModels/Goals/GoalsViewModel.swift +++ b/ViewModels/Goals/GoalsViewModel.swift @@ -13,5 +13,6 @@ final class GoalsViewModel: ObservableObject { currentProtein = planner.currentProtein currentCarbs = planner.currentCarbs currentFat = planner.currentFat + goals = planner.goals } } diff --git a/ViewModels/Planner/MealPlannerViewModel.swift b/ViewModels/Planner/MealPlannerViewModel.swift index b596cf6..8ceda1e 100644 --- a/ViewModels/Planner/MealPlannerViewModel.swift +++ b/ViewModels/Planner/MealPlannerViewModel.swift @@ -1,7 +1,11 @@ // Purpose: -// Main application ViewModel that manages business logic, state management, -// API communication, nutrient optimization (knapsack), meal tracking, -// goals, preferences, and persistence. +// Main application ViewModel. Manages food search, knapsack optimisation, +// meal logging, goals, dietary profile, and persistence. +// +// Multi-search design: searchResults holds the current API page. +// pendingMealFoods is the growing basket the user is building across +// multiple searches. selectedFoodIDs tracks taps within the current page. +// "Add to meal" merges selected into the basket without clearing it. import Foundation import Combine @@ -10,67 +14,53 @@ import SwiftUI final class MealPlannerViewModel: ObservableObject { // MARK: - Services - private let network: NetworkServiceProtocol private let storage: StorageServiceProtocol - // MARK: - User Inputs / State - - @Published var inputErrorMessage: String? - @Published var isLoading = false - - @Published var calorieLimit: String = "" + // MARK: - Search State @Published var query: String = "" + @Published var isLoading = false + @Published var inputErrorMessage: String? - @Published var goals = Goals( - calories: 2000, - protein: 150, - fat: 65, - carbs: 250 - ) - - // MARK: - Dietary Profile - - @Published var dietaryProfile = DietaryProfile() - - // MARK: - Search Results (free search — no knapsack until save) - + /// Current search page results @Published var searchResults: [Food] = [] + /// IDs selected in the current search page @Published var selectedFoodIDs: Set = [] + /// Smart picks computed from current searchResults + @Published var smartPickResults: [Food] = [] + /// Toggle: false = all results, true = knapsack-filtered smart picks + @Published var showSmartPicks: Bool = false - // MARK: - Smart Recommendations - - @Published var recommendedSearchResults: [Food] = [] - @Published var recommendedSelectedIDs: Set = [] - @Published var recommendedQuery: String = "" - @Published var isLoadingRecommendations = false + /// The meal being built across multiple searches + @Published var pendingMealFoods: [Food] = [] - // MARK: - Legacy (kept for history compatibility) + // MARK: - Dietary Profile + @Published var dietaryProfile = DietaryProfile() + // MARK: - Legacy @Published var chosenFoods: [Food] = [] @Published var mealHistory: [[Food]] = [] - // MARK: - Preferences - + // MARK: - Preferences — all default ON @Published var trackCalories: Bool = true - @Published var trackProtein: Bool = true - @Published var trackFat: Bool = false - @Published var trackCarbs: Bool = false + @Published var trackProtein: Bool = true + @Published var trackFat: Bool = true + @Published var trackCarbs: Bool = true - // MARK: - Current Progress + // MARK: - Goals + @Published var goals = Goals(calories: 2000, protein: 150, fat: 65, carbs: 250) + // MARK: - Current Progress @Published var currentCalories: Double = 0 - @Published var currentProtein: Double = 0 - @Published var currentFat: Double = 0 - @Published var currentCarbs: Double = 0 + @Published var currentProtein: Double = 0 + @Published var currentFat: Double = 0 + @Published var currentCarbs: Double = 0 // MARK: - Historical Tracking - - @Published var trackedDays: [TrackedDay] = [] - @Published var dailyMeals: [String: [Meal]] = [:] + @Published var trackedDays: [TrackedDay] = [] + @Published var dailyMeals: [String: [Meal]] = [:] // MARK: - Persistence Keys - private let historyKey = "MealHistory" private let prefsKey = "NutrientPrefs" private let goalsKey = "UserGoals" @@ -79,14 +69,12 @@ final class MealPlannerViewModel: ObservableObject { private let dietaryProfileKey = "DietaryProfile" // MARK: - Init - init( network: NetworkServiceProtocol = NetworkService(), storage: StorageServiceProtocol = StorageService() ) { self.network = network self.storage = storage - loadHistory() loadPreferences() loadGoals() @@ -96,11 +84,10 @@ final class MealPlannerViewModel: ObservableObject { updateTodayProgress() } - // MARK: - Free Food Search (original card — no knapsack until save) + // MARK: - Search func fetchFood() { inputErrorMessage = nil - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { inputErrorMessage = "Please enter a food name." @@ -109,161 +96,120 @@ final class MealPlannerViewModel: ObservableObject { Task { [weak self] in guard let self else { return } - await MainActor.run { self.isLoading = true + // Only clear the search page — do NOT touch pendingMealFoods or selectedFoodIDs for basket items self.searchResults = [] + self.smartPickResults = [] self.selectedFoodIDs = [] } - do { let foods = try await self.searchOpenFoodFactsRaw(query: trimmed) - await self.finishFreeSearch(foods) + await self.finishSearch(foods) } catch { do { let foods = try await self.searchUSDAraw(query: trimmed) - await self.finishFreeSearch(foods) + await self.finishSearch(foods) } catch { - let foods = self.localFallbackFoods(query: trimmed) - await self.finishFreeSearch(foods) + await self.finishSearch(self.localFallbackFoods(query: trimmed)) } } } } @MainActor - private func finishFreeSearch(_ foods: [Food]) { - self.searchResults = foods - self.isLoading = false - if foods.isEmpty { - self.inputErrorMessage = "No foods found." - } + private func finishSearch(_ foods: [Food]) { + searchResults = foods + smartPickResults = computeSmartPicks(from: foods) + isLoading = false + if foods.isEmpty { inputErrorMessage = "No foods found." } } - // MARK: - Save Selected Foods (runs knapsack at save time) + func refreshSmartPicks() { + smartPickResults = computeSmartPicks(from: searchResults) + selectedFoodIDs = [] + } - func saveSelectedFoods() { - let selected = searchResults.filter { selectedFoodIDs.contains($0.id) } - guard !selected.isEmpty else { return } + private func computeSmartPicks(from foods: [Food]) -> [Food] { + let filtered = foods.filter { dietaryProfile.allows($0) } + return knapsack(foods: filtered, calorieLimit: goals.calories, + proteinTarget: goals.protein, fatTarget: goals.fat, carbTarget: goals.carbs) + } - let limit = Double(calorieLimit) ?? goals.calories - let optimized = knapsack( - foods: selected, - calorieLimit: limit, - proteinTarget: goals.protein, - fatTarget: goals.fat, - carbTarget: goals.carbs - ) + var visibleResults: [Food] { showSmartPicks ? smartPickResults : searchResults } - mealHistory.append(optimized) - persistHistory() + // MARK: - Basket Management (multi-search meal building) - let meal = Meal( - date: Date(), - items: optimized.map { MealItem(food: $0, amount: 100, unit: .gram) }, - totalCalories: optimized.reduce(0) { $0 + $1.calories }, - totalProtein: optimized.reduce(0) { $0 + $1.protein }, - totalFat: optimized.reduce(0) { $0 + $1.fat }, - totalCarbs: optimized.reduce(0) { $0 + $1.carbs } - ) - addMealToToday(meal) - updateTodayProgress() - - searchResults = [] + /// Add selected foods from the current page into the pending meal basket + func addSelectionToMeal() { + let toAdd = visibleResults.filter { selectedFoodIDs.contains($0.id) } + guard !toAdd.isEmpty else { return } + // Avoid duplicates if user searches same food twice + let existingIDs = Set(pendingMealFoods.map { $0.id }) + pendingMealFoods.append(contentsOf: toAdd.filter { !existingIDs.contains($0.id) }) selectedFoodIDs = [] } - // MARK: - Smart Recommendations - - func fetchRecommendations() { - let baseQuery = recommendedQuery.trimmingCharacters(in: .whitespacesAndNewlines) - let suffix = dietaryProfile.searchSuffix - let fullQuery: String = { - if baseQuery.isEmpty { - return suffix.isEmpty ? "healthy meal" : suffix - } else { - return suffix.isEmpty ? baseQuery : "\(baseQuery) \(suffix)" - } - }() - - Task { [weak self] in - guard let self else { return } - - await MainActor.run { - self.isLoadingRecommendations = true - self.recommendedSearchResults = [] - self.recommendedSelectedIDs = [] - } + func removeFromPendingMeal(_ food: Food) { + pendingMealFoods.removeAll { $0.id == food.id } + } - do { - let foods = try await self.searchOpenFoodFactsRaw(query: fullQuery) - let filtered = foods.filter { self.dietaryProfile.allows($0) } - let optimized = self.knapsack( - foods: filtered, - calorieLimit: goals.calories, - proteinTarget: goals.protein, - fatTarget: goals.fat, - carbTarget: goals.carbs - ) - await MainActor.run { - self.recommendedSearchResults = optimized - self.isLoadingRecommendations = false - if optimized.isEmpty { - self.inputErrorMessage = "No recommendations found for your dietary preferences." - } - } - } catch { - await MainActor.run { - self.isLoadingRecommendations = false - self.inputErrorMessage = "Could not load recommendations." - } - } - } + func clearPendingMeal() { + pendingMealFoods = [] + selectedFoodIDs = [] + searchResults = [] + smartPickResults = [] + showSmartPicks = false } - func saveRecommendedSelection() { - let toSave = recommendedSearchResults.filter { recommendedSelectedIDs.contains($0.id) } - guard !toSave.isEmpty else { return } + /// Save everything in the pending basket as one meal + func savePendingMeal() { + guard !pendingMealFoods.isEmpty else { return } - mealHistory.append(toSave) + mealHistory.append(pendingMealFoods) persistHistory() let meal = Meal( date: Date(), - items: toSave.map { MealItem(food: $0, amount: 100, unit: .gram) }, - totalCalories: toSave.reduce(0) { $0 + $1.calories }, - totalProtein: toSave.reduce(0) { $0 + $1.protein }, - totalFat: toSave.reduce(0) { $0 + $1.fat }, - totalCarbs: toSave.reduce(0) { $0 + $1.carbs } + items: pendingMealFoods.map { MealItem(food: $0, amount: 100, unit: .gram) }, + totalCalories: pendingMealFoods.reduce(0) { $0 + $1.calories }, + totalProtein: pendingMealFoods.reduce(0) { $0 + $1.protein }, + totalFat: pendingMealFoods.reduce(0) { $0 + $1.fat }, + totalCarbs: pendingMealFoods.reduce(0) { $0 + $1.carbs } ) addMealToToday(meal) updateTodayProgress() - - recommendedSearchResults = [] - recommendedSelectedIDs = [] + clearPendingMeal() } - // MARK: - Knapsack Algorithm (whole items only, multi-macro scoring) + // MARK: - Basket Totals - func knapsack( - foods: [Food], - calorieLimit: Double, - proteinTarget: Double = 0, - fatTarget: Double = 0, - carbTarget: Double = 0 - ) -> [Food] { - guard calorieLimit > 0, !foods.isEmpty else { return foods } + var pendingCalories: Double { pendingMealFoods.reduce(0) { $0 + $1.calories } } + var pendingProtein: Double { pendingMealFoods.reduce(0) { $0 + $1.protein } } + var pendingFat: Double { pendingMealFoods.reduce(0) { $0 + $1.fat } } + var pendingCarbs: Double { pendingMealFoods.reduce(0) { $0 + $1.carbs } } + // Selection totals (current page only) + func totalCalories() -> Double { visibleResults.filter { selectedFoodIDs.contains($0.id) }.reduce(0) { $0 + $1.calories } } + func totalProtein() -> Double { visibleResults.filter { selectedFoodIDs.contains($0.id) }.reduce(0) { $0 + $1.protein } } + func totalFat() -> Double { visibleResults.filter { selectedFoodIDs.contains($0.id) }.reduce(0) { $0 + $1.fat } } + func totalCarbs() -> Double { visibleResults.filter { selectedFoodIDs.contains($0.id) }.reduce(0) { $0 + $1.carbs } } + + // MARK: - Knapsack + + func knapsack(foods: [Food], calorieLimit: Double, + proteinTarget: Double = 0, fatTarget: Double = 0, carbTarget: Double = 0) -> [Food] { + guard calorieLimit > 0, !foods.isEmpty else { return foods } let eps = 0.0001 let totalTarget = (proteinTarget * 4) + (fatTarget * 9) + (carbTarget * 4) func score(_ food: Food) -> Double { guard food.calories > eps else { return 0 } if totalTarget > 0 { - let pScore = proteinTarget > 0 ? (food.protein * 4 / totalTarget) : 0 - let fScore = fatTarget > 0 ? (food.fat * 9 / totalTarget) : 0 - let cScore = carbTarget > 0 ? (food.carbs * 4 / totalTarget) : 0 - return (pScore + fScore + cScore) / food.calories + let p = proteinTarget > 0 ? (food.protein * 4 / totalTarget) : 0 + let f = fatTarget > 0 ? (food.fat * 9 / totalTarget) : 0 + let c = carbTarget > 0 ? (food.carbs * 4 / totalTarget) : 0 + return (p + f + c) / food.calories } if trackProtein { return food.protein / food.calories } if trackFat { return food.fat / food.calories } @@ -271,45 +217,32 @@ final class MealPlannerViewModel: ObservableObject { return 1.0 / food.calories } - let sorted = foods.filter { $0.calories > 0 }.sorted { score($0) > score($1) } var remaining = calorieLimit var selected: [Food] = [] - - for food in sorted { - guard food.calories > 0 else { continue } - if food.calories <= remaining { - selected.append(food) - remaining -= food.calories - } - // No fractions — whole items only + for food in foods.filter({ $0.calories > 0 }).sorted(by: { score($0) > score($1) }) { + if food.calories <= remaining { selected.append(food); remaining -= food.calories } } - return selected } - // MARK: - Meal Management (legacy saveMeal kept for compatibility) + // MARK: - Legacy saveMeal func saveMeal() { guard !chosenFoods.isEmpty else { return } mealHistory.append(chosenFoods) persistHistory() - let meal = createMealFromChosenFoods() + let meal = Meal(date: Date(), + items: chosenFoods.map { MealItem(food: $0, amount: 100, unit: .gram) }, + totalCalories: chosenFoods.reduce(0) { $0 + $1.calories }, + totalProtein: chosenFoods.reduce(0) { $0 + $1.protein }, + totalFat: chosenFoods.reduce(0) { $0 + $1.fat }, + totalCarbs: chosenFoods.reduce(0) { $0 + $1.carbs }) addMealToToday(meal) updateTodayProgress() chosenFoods = [] } - private func createMealFromChosenFoods() -> Meal { - let items = chosenFoods.map { MealItem(food: $0, amount: 100, unit: .gram) } - return Meal( - date: Date(), - items: items, - totalCalories: chosenFoods.reduce(0) { $0 + $1.calories }, - totalProtein: chosenFoods.reduce(0) { $0 + $1.protein }, - totalFat: chosenFoods.reduce(0) { $0 + $1.fat }, - totalCarbs: chosenFoods.reduce(0) { $0 + $1.carbs } - ) - } + // MARK: - Meal Management private func addMealToToday(_ meal: Meal) { let key = dateString(from: Date()) @@ -319,9 +252,7 @@ final class MealPlannerViewModel: ObservableObject { persistDailyMeals() } - func getMeals(for date: Date) -> [Meal] { - dailyMeals[dateString(from: date)] ?? [] - } + func getMeals(for date: Date) -> [Meal] { dailyMeals[dateString(from: date)] ?? [] } // MARK: - Progress @@ -336,16 +267,14 @@ final class MealPlannerViewModel: ObservableObject { private func updateTrackedDay(for date: Date) { let meals = getMeals(for: date) - let trackedDay = TrackedDay( - date: date, - calories: meals.reduce(0) { $0 + $1.totalCalories }, - protein: meals.reduce(0) { $0 + $1.totalProtein }, - carbs: meals.reduce(0) { $0 + $1.totalCarbs }, - fat: meals.reduce(0) { $0 + $1.totalFat }, - goalCalories: goals.calories - ) + let day = TrackedDay(date: date, + calories: meals.reduce(0) { $0 + $1.totalCalories }, + protein: meals.reduce(0) { $0 + $1.totalProtein }, + carbs: meals.reduce(0) { $0 + $1.totalCarbs }, + fat: meals.reduce(0) { $0 + $1.totalFat }, + goalCalories: goals.calories) trackedDays.removeAll { Calendar.current.isDate($0.date, inSameDayAs: date) } - trackedDays.append(trackedDay) + trackedDays.append(day) persistTrackedDays() } @@ -353,167 +282,104 @@ final class MealPlannerViewModel: ObservableObject { trackedDays.first { Calendar.current.isDate($0.date, inSameDayAs: date) } } - // MARK: - Totals (for selected foods in free search) - - func totalCalories() -> Double { - searchResults.filter { selectedFoodIDs.contains($0.id) }.reduce(0) { $0 + $1.calories } - } - - func totalProtein() -> Double { - searchResults.filter { selectedFoodIDs.contains($0.id) }.reduce(0) { $0 + $1.protein } - } - - func totalFat() -> Double { - searchResults.filter { selectedFoodIDs.contains($0.id) }.reduce(0) { $0 + $1.fat } - } - - func totalCarbs() -> Double { - searchResults.filter { selectedFoodIDs.contains($0.id) }.reduce(0) { $0 + $1.carbs } - } - // MARK: - Persistence func saveGoals() { - if let data = try? JSONEncoder().encode(goals) { - storage.set(data, forKey: goalsKey) - } + if let data = try? JSONEncoder().encode(goals) { storage.set(data, forKey: goalsKey) } } private func loadGoals() { guard let data = storage.data(forKey: goalsKey), - let decoded = try? JSONDecoder().decode(Goals.self, from: data) - else { return } + let decoded = try? JSONDecoder().decode(Goals.self, from: data) else { return } goals = decoded } func saveDietaryProfile() { - if let data = try? JSONEncoder().encode(dietaryProfile) { - storage.set(data, forKey: dietaryProfileKey) - } + if let data = try? JSONEncoder().encode(dietaryProfile) { storage.set(data, forKey: dietaryProfileKey) } } private func loadDietaryProfile() { guard let data = storage.data(forKey: dietaryProfileKey), - let decoded = try? JSONDecoder().decode(DietaryProfile.self, from: data) - else { return } + let decoded = try? JSONDecoder().decode(DietaryProfile.self, from: data) else { return } dietaryProfile = decoded } func savePreferences() { - let prefs: [String: Bool] = [ - "calories": trackCalories, - "protein": trackProtein, - "fat": trackFat, - "carbs": trackCarbs - ] - storage.set(prefs, forKey: prefsKey) + storage.set(["calories": trackCalories, "protein": trackProtein, + "fat": trackFat, "carbs": trackCarbs], forKey: prefsKey) } private func loadPreferences() { - guard let prefs = storage.dictionary(forKey: prefsKey) as? [String: Bool] else { return } - trackCalories = prefs["calories"] ?? true - trackProtein = prefs["protein"] ?? true - trackFat = prefs["fat"] ?? false - trackCarbs = prefs["carbs"] ?? false + guard let p = storage.dictionary(forKey: prefsKey) as? [String: Bool] else { return } + trackCalories = p["calories"] ?? true + trackProtein = p["protein"] ?? true + trackFat = p["fat"] ?? true + trackCarbs = p["carbs"] ?? true } private func persistHistory() { - if let data = try? JSONEncoder().encode(mealHistory) { - storage.set(data, forKey: historyKey) - } + if let data = try? JSONEncoder().encode(mealHistory) { storage.set(data, forKey: historyKey) } } private func loadHistory() { guard let data = storage.data(forKey: historyKey), - let decoded = try? JSONDecoder().decode([[Food]].self, from: data) - else { return } + let decoded = try? JSONDecoder().decode([[Food]].self, from: data) else { return } mealHistory = decoded } private func persistTrackedDays() { - if let data = try? JSONEncoder().encode(trackedDays) { - storage.set(data, forKey: trackedDaysKey) - } + if let data = try? JSONEncoder().encode(trackedDays) { storage.set(data, forKey: trackedDaysKey) } } private func loadTrackedDays() { guard let data = storage.data(forKey: trackedDaysKey), - let decoded = try? JSONDecoder().decode([TrackedDay].self, from: data) - else { return } + let decoded = try? JSONDecoder().decode([TrackedDay].self, from: data) else { return } trackedDays = decoded } private func persistDailyMeals() { - if let data = try? JSONEncoder().encode(dailyMeals) { - storage.set(data, forKey: dailyMealsKey) - } + if let data = try? JSONEncoder().encode(dailyMeals) { storage.set(data, forKey: dailyMealsKey) } } private func loadDailyMeals() { guard let data = storage.data(forKey: dailyMealsKey), - let decoded = try? JSONDecoder().decode([String: [Meal]].self, from: data) - else { return } + let decoded = try? JSONDecoder().decode([String: [Meal]].self, from: data) else { return } dailyMeals = decoded } // MARK: - Helpers private func dateString(from date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - return formatter.string(from: date) + let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.string(from: date) } - // MARK: - Raw Search Helpers (no knapsack — return full lists) - func searchOpenFoodFactsRaw(query: String) async throws -> [Food] { let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query - guard let url = URL(string: - "https://world.openfoodfacts.org/cgi/search.pl?search_terms=\(encoded)&search_simple=1&action=process&json=1&page_size=30" - ) else { throw URLError(.badURL) } - + guard let url = URL(string: "https://world.openfoodfacts.org/cgi/search.pl?search_terms=\(encoded)&search_simple=1&action=process&json=1&page_size=30") + else { throw URLError(.badURL) } let data = try await network.get(url: url, ttl: 300) let decoded = try JSONDecoder().decode(OpenFoodFactsResponse.self, from: data) - - return decoded.products.compactMap { product in - guard let name = product.product_name, !name.isEmpty, - let nutr = product.nutriments else { return nil } - return Food( - name: name, - calories: nutr.energyKcal100g ?? 0, - protein: nutr.proteins100g ?? 0, - fat: nutr.fat100g ?? 0, - carbs: nutr.carbohydrates100g ?? 0 - ) + return decoded.products.compactMap { p in + guard let name = p.product_name, !name.isEmpty, let n = p.nutriments else { return nil } + return Food(name: name, calories: n.energyKcal100g ?? 0, protein: n.proteins100g ?? 0, + fat: n.fat100g ?? 0, carbs: n.carbohydrates100g ?? 0) } } private func searchUSDAraw(query: String) async throws -> [Food] { let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query - guard let url = URL(string: - "https://api.nal.usda.gov/fdc/v1/foods/search?query=\(encoded)&api_key=\(Secrets.usdaKey)" - ) else { throw URLError(.badURL) } - + guard let url = URL(string: "https://api.nal.usda.gov/fdc/v1/foods/search?query=\(encoded)&api_key=\(Secrets.usdaKey)") + else { throw URLError(.badURL) } let data = try await network.get(url: url, ttl: 300) let decoded = try JSONDecoder().decode(USDAResponse.self, from: data) - - return decoded.foods.map { item in - Food( - name: item.description, - calories: item.calories, - protein: item.protein, - fat: item.fat, - carbs: item.carbs - ) - } + return decoded.foods.map { Food(name: $0.description, calories: $0.calories, protein: $0.protein, + fat: $0.fat, carbs: $0.carbs) } } private func localFallbackFoods(query: String) -> [Food] { - let foods = [ - Food(name: "Cheese Quesadilla", calories: 280, protein: 12, fat: 16, carbs: 22), - Food(name: "Milk", calories: 103, protein: 8, fat: 2, carbs: 12), - Food(name: "Spaghetti", calories: 220, protein: 8, fat: 1, carbs: 43) - ] - return foods.filter { $0.name.lowercased().contains(query.lowercased()) } + [Food(name: "Cheese Quesadilla", calories: 280, protein: 12, fat: 16, carbs: 22), + Food(name: "Milk", calories: 103, protein: 8, fat: 2, carbs: 12), + Food(name: "Spaghetti", calories: 220, protein: 8, fat: 1, carbs: 43)] + .filter { $0.name.lowercased().contains(query.lowercased()) } } } diff --git a/Views/Navigation/TDEECalculatorView.swift b/Views/Navigation/TDEECalculatorView.swift deleted file mode 100644 index ad33057..0000000 --- a/Views/Navigation/TDEECalculatorView.swift +++ /dev/null @@ -1,747 +0,0 @@ -// PURPOSE: Calculates TDEE from user stats and sets macro goals based on bulk / cut / maintain mode. -// HLFR: The system shall allow users to auto-generate daily nutrition goals from personal data. - -import SwiftUI - -// MARK: - Enumerations - -enum BiologicalSex: String, CaseIterable, Identifiable { - case male = "Male" - case female = "Female" - var id: String { rawValue } -} - -enum ActivityLevel: String, CaseIterable, Identifiable { - case sedentary = "Sedentary" - case light = "Lightly Active" - case moderate = "Moderately Active" - case active = "Very Active" - case extraActive = "Extra Active" - - var id: String { rawValue } - - /// Multiplier for TDEE calculation for activity level - var multiplier: Double { - switch self { - case .sedentary: return 1.2 - case .light: return 1.375 - case .moderate: return 1.55 - case .active: return 1.725 - case .extraActive: return 1.9 - } - } - - var description: String { - switch self { - case .sedentary: return "Little/no exercise" - case .light: return "Light exercise 1–3 days/week" - case .moderate: return "Moderate exercise 3–5 days/week" - case .active: return "Hard exercise 6–7 days/week" - case .extraActive: return "Physical job + hard training" - } - } -} - -enum PhysiqGoal: String, CaseIterable, Identifiable { - case cutting = "Cut" - case maintaining = "Maintain" - case bulking = "Bulk" - - var id: String { rawValue } - - /// Calorie adjustment relative to TDEE - var calorieAdjustment: Double { - switch self { - case .cutting: return -500 - case .maintaining: return 0 - case .bulking: return +300 - } - } - - var emoji: String { - switch self { - case .cutting: return "🔥" - case .maintaining: return "⚖️" - case .bulking: return "💪" - } - } - - var description: String { - switch self { - case .cutting: return "Lose ~0.5 kg/week" - case .maintaining: return "Hold current weight" - case .bulking: return "Gain ~0.3 kg/week" - } - } - - var color: Color { - switch self { - case .cutting: return Theme.warning - case .maintaining: return Theme.primary - case .bulking: return Theme.success - } - } -} - -enum WeightUnit: String, CaseIterable, Identifiable { - case kg = "kg" - case lbs = "lbs" - var id: String { rawValue } -} - -enum HeightUnit: String, CaseIterable, Identifiable { - case cm = "cm" - case ft = "ft/in" - var id: String { rawValue } -} - -// MARK: - Main View - -struct TDEECalculatorView: View { - - @ObservedObject var vm: MealPlannerViewModel - @Environment(\.dismiss) private var dismiss - - // User inputs - @State private var age: String = "" - @State private var weightValue: String = "" - @State private var heightCm: String = "" - @State private var heightFt: String = "" - @State private var heightIn: String = "" - @State private var sex: BiologicalSex = .male - @State private var activityLevel: ActivityLevel = .moderate - @State private var physiqGoal: PhysiqGoal = .maintaining - @State private var weightUnit: WeightUnit = .kg - @State private var heightUnit: HeightUnit = .cm - - // Result state - @State private var result: TDEEResult? = nil - @State private var showResult = false - @State private var showApplyConfirm = false - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 20) { - - // MARK: Header banner - headerBanner - - // MARK: Personal info - inputSection("Personal Info", icon: "person.fill") { - sexPicker - agePicker - } - - // MARK: Body measurements - inputSection("Body Measurements", icon: "ruler.fill") { - weightRow - heightRow - } - - // MARK: Activity level - inputSection("Activity Level", icon: "figure.run") { - activityPicker - } - - // MARK: Goal - inputSection("Your Goal", icon: "target") { - goalPicker - } - - // MARK: Calculate button - calculateButton - - // MARK: Result card - if showResult, let res = result { - resultCard(res) - .transition(.opacity.combined(with: .move(edge: .bottom))) - } - - Spacer(minLength: 20) - } - .padding() - } - .background(Theme.surface.ignoresSafeArea()) - .navigationTitle("Goal Calculator") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Close") { dismiss() } - .foregroundColor(Theme.primary) - } - } - .alert("Apply these goals?", isPresented: $showApplyConfirm) { - Button("Apply") { - if let res = result { applyGoals(res) } - } - Button("Cancel", role: .cancel) {} - } message: { - if let res = result { - Text("This will update your daily targets to \(Int(res.targetCalories)) kcal and set your macro splits.") - } else { - Text("This will update your daily targets.") - } - } - } - } - - // MARK: - Header - - private var headerBanner: some View { - HStack(spacing: 14) { - Image(systemName: "bolt.heart.fill") - .font(.largeTitle) - .foregroundColor(Theme.primary) - - VStack(alignment: .leading, spacing: 4) { - Text("TDEE Calculator") - .font(.headline) - .foregroundColor(Theme.textPrimary) - Text("Get personalised calorie & macro targets based on your stats and goal.") - .font(.caption) - .foregroundColor(Theme.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.primary.opacity(0.08)) - .cornerRadius(16) - .overlay( - RoundedRectangle(cornerRadius: 16) - .stroke(Theme.primary.opacity(0.2), lineWidth: 1) - ) - } - - // MARK: - Section wrapper - - private func inputSection( - _ title: String, - icon: String, - @ViewBuilder content: () -> Content - ) -> some View { - VStack(alignment: .leading, spacing: 14) { - HStack(spacing: 8) { - Image(systemName: icon) - .foregroundColor(Theme.primary) - Text(title) - .font(.headline) - .foregroundColor(Theme.textPrimary) - } - content() - } - .padding() - .background(Theme.surface) - .cornerRadius(16) - .overlay( - RoundedRectangle(cornerRadius: 16) - .stroke(Theme.textSecondary.opacity(0.2), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.04), radius: 6, y: 3) - } - - // MARK: - Input fields - - private var sexPicker: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Biological Sex") - .font(.subheadline) - .foregroundColor(Theme.textSecondary) - Picker("Sex", selection: $sex) { - ForEach(BiologicalSex.allCases) { s in - Text(s.rawValue).tag(s) - } - } - .pickerStyle(.segmented) - } - } - - private var agePicker: some View { - HStack { - Text("Age") - .foregroundColor(Theme.textPrimary) - Spacer() - TextField("e.g. 25", text: $age) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - .foregroundColor(Theme.textPrimary) - .frame(width: 80) - .padding(8) - .background(Theme.primary.opacity(0.06)) - .cornerRadius(8) - Text("yrs") - .foregroundColor(Theme.textSecondary) - .font(.caption) - } - } - - private var weightRow: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Weight") - .foregroundColor(Theme.textPrimary) - Spacer() - Picker("Unit", selection: $weightUnit) { - ForEach(WeightUnit.allCases) { u in Text(u.rawValue).tag(u) } - } - .pickerStyle(.segmented) - .frame(width: 110) - } - - HStack { - TextField(weightUnit == .kg ? "e.g. 75" : "e.g. 165", text: $weightValue) - .keyboardType(.decimalPad) - .foregroundColor(Theme.textPrimary) - .padding(8) - .background(Theme.primary.opacity(0.06)) - .cornerRadius(8) - Text(weightUnit.rawValue) - .foregroundColor(Theme.textSecondary) - .font(.caption) - } - } - } - - private var heightRow: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Height") - .foregroundColor(Theme.textPrimary) - Spacer() - Picker("Unit", selection: $heightUnit) { - ForEach(HeightUnit.allCases) { u in Text(u.rawValue).tag(u) } - } - .pickerStyle(.segmented) - .frame(width: 110) - } - - if heightUnit == .cm { - HStack { - TextField("e.g. 175", text: $heightCm) - .keyboardType(.decimalPad) - .foregroundColor(Theme.textPrimary) - .padding(8) - .background(Theme.primary.opacity(0.06)) - .cornerRadius(8) - Text("cm") - .foregroundColor(Theme.textSecondary) - .font(.caption) - } - } else { - HStack(spacing: 10) { - HStack { - TextField("5", text: $heightFt) - .keyboardType(.numberPad) - .foregroundColor(Theme.textPrimary) - .padding(8) - .background(Theme.primary.opacity(0.06)) - .cornerRadius(8) - Text("ft") - .foregroundColor(Theme.textSecondary) - .font(.caption) - } - HStack { - TextField("10", text: $heightIn) - .keyboardType(.numberPad) - .foregroundColor(Theme.textPrimary) - .padding(8) - .background(Theme.primary.opacity(0.06)) - .cornerRadius(8) - Text("in") - .foregroundColor(Theme.textSecondary) - .font(.caption) - } - } - } - } - } - - private var activityPicker: some View { - VStack(spacing: 10) { - ForEach(ActivityLevel.allCases) { level in - Button { - activityLevel = level - } label: { - HStack(spacing: 12) { - Circle() - .fill(activityLevel == level ? Theme.primary : Theme.textSecondary.opacity(0.2)) - .frame(width: 12, height: 12) - - VStack(alignment: .leading, spacing: 2) { - Text(level.rawValue) - .font(.subheadline).bold() - .foregroundColor(Theme.textPrimary) - Text(level.description) - .font(.caption) - .foregroundColor(Theme.textSecondary) - } - Spacer() - - if activityLevel == level { - Image(systemName: "checkmark") - .font(.caption.bold()) - .foregroundColor(Theme.primary) - } - } - .padding(12) - .background(activityLevel == level ? Theme.primary.opacity(0.06) : Color.clear) - .cornerRadius(10) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(activityLevel == level ? Theme.primary.opacity(0.3) : Theme.textSecondary.opacity(0.15), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .animation(.easeInOut(duration: 0.15), value: activityLevel) - } - } - } - - private var goalPicker: some View { - HStack(spacing: 10) { - ForEach(PhysiqGoal.allCases) { goal in - Button { - physiqGoal = goal - } label: { - VStack(spacing: 6) { - Text(goal.emoji) - .font(.title2) - Text(goal.rawValue) - .font(.subheadline).bold() - .foregroundColor(physiqGoal == goal ? .white : Theme.textPrimary) - Text(goal.description) - .font(.caption2) - .foregroundColor(physiqGoal == goal ? .white.opacity(0.8) : Theme.textSecondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(physiqGoal == goal ? goal.color : goal.color.opacity(0.08)) - .cornerRadius(14) - .overlay( - RoundedRectangle(cornerRadius: 14) - .stroke(goal.color.opacity(physiqGoal == goal ? 0 : 0.3), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .animation(.easeInOut(duration: 0.15), value: physiqGoal) - } - } - } - - // MARK: - Calculate button - - private var calculateButton: some View { - Button { - withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { - result = calculate() - showResult = result != nil - } - } label: { - HStack { - Image(systemName: "function") - Text("Calculate My Goals") - .bold() - } - .frame(maxWidth: .infinity) - .padding() - .background(isFormValid ? Theme.primary : Theme.textSecondary.opacity(0.3)) - .foregroundColor(.white) - .cornerRadius(14) - } - .disabled(!isFormValid) - } - - // MARK: - Result card - - private func resultCard(_ res: TDEEResult) -> some View { - VStack(spacing: 16) { - - // Title row - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Your Results") - .font(.headline) - .foregroundColor(Theme.textPrimary) - Text(physiqGoal.rawValue + " · " + activityLevel.rawValue) - .font(.caption) - .foregroundColor(Theme.textSecondary) - } - Spacer() - Text(physiqGoal.emoji) - .font(.largeTitle) - } - - Divider() - - // TDEE vs Target - HStack(spacing: 0) { - tdeeStatBlock( - title: "TDEE", - subtitle: "Maintenance", - value: "\(Int(res.tdee))", - unit: "kcal", - color: Theme.primary - ) - Divider().frame(height: 60) - tdeeStatBlock( - title: "Target", - subtitle: physiqGoal.rawValue, - value: "\(Int(res.targetCalories))", - unit: "kcal", - color: physiqGoal.color - ) - Divider().frame(height: 60) - tdeeStatBlock( - title: "Adjust", - subtitle: res.adjustmentLabel, - value: res.adjustmentDisplay, - unit: "kcal/day", - color: res.adjustmentCalories == 0 ? Theme.textSecondary : (res.adjustmentCalories > 0 ? Theme.success : Theme.warning) - ) - } - - Divider() - - // Macro breakdown - Text("Macro Targets") - .font(.subheadline).bold() - .foregroundColor(Theme.textPrimary) - .frame(maxWidth: .infinity, alignment: .leading) - - HStack(spacing: 12) { - macroPill("Protein", "\(Int(res.protein))g", color: Theme.success) - macroPill("Carbs", "\(Int(res.carbs))g", color: Theme.primary) - macroPill("Fat", "\(Int(res.fat))g", color: Theme.warning) - } - - // Macro % breakdown bar - macroBar(res) - - // Explanation note - Text(physiqGoal.explanation) - .font(.caption) - .foregroundColor(Theme.textSecondary) - .padding(10) - .background(physiqGoal.color.opacity(0.07)) - .cornerRadius(10) - - // Apply button - Button { - showApplyConfirm = true - } label: { - HStack { - Image(systemName: "checkmark.circle.fill") - Text("Apply These Goals") - .bold() - } - .frame(maxWidth: .infinity) - .padding() - .background(physiqGoal.color) - .foregroundColor(.white) - .cornerRadius(14) - } - } - .padding() - .background(Theme.surface) - .cornerRadius(20) - .overlay( - RoundedRectangle(cornerRadius: 20) - .stroke(physiqGoal.color.opacity(0.3), lineWidth: 1.5) - ) - .shadow(color: physiqGoal.color.opacity(0.1), radius: 12, y: 6) - } - - private func tdeeStatBlock(title: String, subtitle: String, value: String, unit: String, color: Color) -> some View { - VStack(spacing: 4) { - Text(title) - .font(.caption).bold() - .foregroundColor(Theme.textSecondary) - Text(value) - .font(.title3).bold() - .foregroundColor(color) - .monospacedDigit() - Text(unit) - .font(.caption2) - .foregroundColor(Theme.textSecondary) - Text(subtitle) - .font(.caption2) - .foregroundColor(Theme.textSecondary) - } - .frame(maxWidth: .infinity) - } - - private func macroPill(_ title: String, _ value: String, color: Color) -> some View { - VStack(spacing: 6) { - Circle().fill(color).frame(width: 8, height: 8) - Text(value) - .font(.subheadline).bold() - .foregroundColor(Theme.textPrimary) - Text(title) - .font(.caption2) - .foregroundColor(Theme.textSecondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background(color.opacity(0.08)) - .cornerRadius(12) - } - - private func macroBar(_ res: TDEEResult) -> some View { - let total = res.protein * 4 + res.carbs * 4 + res.fat * 9 - let pPct = total > 0 ? (res.protein * 4) / total : 0 - let cPct = total > 0 ? (res.carbs * 4) / total : 0 - let fPct = total > 0 ? (res.fat * 9) / total : 0 - - return GeometryReader { geo in - HStack(spacing: 2) { - Capsule().fill(Theme.success) - .frame(width: geo.size.width * CGFloat(pPct)) - Capsule().fill(Theme.primary) - .frame(width: geo.size.width * CGFloat(cPct)) - Capsule().fill(Theme.warning) - .frame(width: geo.size.width * CGFloat(fPct)) - } - } - .frame(height: 10) - .clipShape(Capsule()) - } - - // MARK: - Validation - - private var isFormValid: Bool { - guard - let a = Int(age), a > 0, a < 120, - let w = Double(weightValue), w > 0 - else { return false } - - if heightUnit == .cm { - guard let h = Double(heightCm), h > 0 else { return false } - } else { - guard let _ = Double(heightFt), let _ = Double(heightIn) else { return false } - } - return true - } - - // MARK: - Calculation logic - - private func calculate() -> TDEEResult? { - guard - let ageVal = Double(age), - let weightRaw = Double(weightValue) - else { return nil } - - let weightKg = weightUnit == .kg ? weightRaw : weightRaw * 0.453592 - - let heightCmVal: Double - if heightUnit == .cm { - guard let h = Double(heightCm) else { return nil } - heightCmVal = h - } else { - let ft = Double(heightFt) ?? 0 - let inches = Double(heightIn) ?? 0 - heightCmVal = (ft * 12 + inches) * 2.54 - } - - guard heightCmVal > 0 else { return nil } - - // Mifflin-St Jeor BMR - let bmr: Double - switch sex { - case .male: - bmr = (10 * weightKg) + (6.25 * heightCmVal) - (5 * ageVal) + 5 - case .female: - bmr = (10 * weightKg) + (6.25 * heightCmVal) - (5 * ageVal) - 161 - } - - let tdee = bmr * activityLevel.multiplier - let targetCalories = max(1200, tdee + physiqGoal.calorieAdjustment) - - // Macro splits based on goal - let proteinPerKg: Double - let fatPct: Double - - switch physiqGoal { - case .cutting: - proteinPerKg = 2.4 // Higher protein to preserve muscle - fatPct = 0.25 - case .maintaining: - proteinPerKg = 2.0 - fatPct = 0.30 - case .bulking: - proteinPerKg = 2.2 // Slightly elevated for muscle growth - fatPct = 0.28 - } - - let protein = weightKg * proteinPerKg - let fat = (targetCalories * fatPct) / 9 - let carbCals = targetCalories - (protein * 4) - (fat * 9) - let carbs = max(carbCals / 4, 0) - - return TDEEResult( - bmr: bmr, - tdee: tdee, - targetCalories: targetCalories, - protein: protein, - carbs: carbs, - fat: fat, - adjustmentCalories: physiqGoal.calorieAdjustment - ) - } - - // MARK: - Apply - - private func applyGoals(_ res: TDEEResult) { - vm.goals.calories = res.targetCalories - vm.goals.protein = res.protein - vm.goals.carbs = res.carbs - vm.goals.fat = res.fat - vm.saveGoals() - dismiss() - } -} - -// Container to hold the calculated output - -struct TDEEResult { - let bmr: Double - let tdee: Double - let targetCalories: Double - let protein: Double - let carbs: Double - let fat: Double - let adjustmentCalories: Double - - var adjustmentDisplay: String { - if adjustmentCalories == 0 { return "±0" } - return adjustmentCalories > 0 ? "+\(Int(adjustmentCalories))" : "\(Int(adjustmentCalories))" - } - - var adjustmentLabel: String { - if adjustmentCalories == 0 { return "None" } - return adjustmentCalories > 0 ? "Surplus" : "Deficit" - } -} - -// MARK: - PhysiqGoal explanation - -extension PhysiqGoal { - var explanation: String { - switch self { - case .cutting: - return "A 500 kcal daily deficit targets ~0.5 kg of fat loss per week. Protein is set high to preserve muscle mass while in a deficit." - case .maintaining: - return "Calories match your TDEE to sustain your current weight. Great for recomping or taking a diet break." - case .bulking: - return "A 300 kcal surplus supports muscle growth while minimising fat gain. Pair with progressive resistance training." - } - } -} - -// MARK: - Preview - -#Preview { - TDEECalculatorView(vm: MealPlannerViewModel()) - .preferredColorScheme(.light) -} diff --git a/Views/Screens/OnboardingSurveyView.swift b/Views/Screens/OnboardingSurveyView.swift index ad1e037..fc7f61c 100644 --- a/Views/Screens/OnboardingSurveyView.swift +++ b/Views/Screens/OnboardingSurveyView.swift @@ -1,8 +1,7 @@ - - // PURPOSE: Onboarding survey shown to new users on first launch. -// Collects sex, age, body measurements, activity level, and physique goal, -// then calculates TDEE and sets macro targets. Users can skip at any step. +// Collects sex, age, body measurements, activity level, physique goal, +// and dietary preferences. Calculates TDEE and sets macro targets. +// Users can skip at any step. import SwiftUI @@ -26,43 +25,28 @@ final class OnboardingState: ObservableObject { @Published var dietaryProfile = DietaryProfile() var isBodyValid: Bool { - guard - let a = Int(age), a > 0, a < 120, - let w = Double(weightVal), w > 0 - else { return false } - if heightUnit == .cm { - return (Double(heightCm) ?? 0) > 0 - } else { - return (Double(heightFt) ?? 0) > 0 - } + guard let a = Int(age), a > 0, a < 120, + let w = Double(weightVal), w > 0 else { return false } + return heightUnit == .cm + ? (Double(heightCm) ?? 0) > 0 + : (Double(heightFt) ?? 0) > 0 } func calculate() -> TDEEResult? { - guard - let ageVal = Double(age), - let weightRaw = Double(weightVal) - else { return nil } - + guard let ageVal = Double(age), let weightRaw = Double(weightVal) else { return nil } let weightKg = weightUnit == .kg ? weightRaw : weightRaw * 0.453592 - let heightCmVal: Double if heightUnit == .cm { guard let h = Double(heightCm) else { return nil } heightCmVal = h } else { - let ft = Double(heightFt) ?? 0 - let inches = Double(heightIn) ?? 0 - heightCmVal = (ft * 12 + inches) * 2.54 + heightCmVal = ((Double(heightFt) ?? 0) * 12 + (Double(heightIn) ?? 0)) * 2.54 } guard heightCmVal > 0 else { return nil } - let bmr: Double - switch sex { - case .male: - bmr = (10 * weightKg) + (6.25 * heightCmVal) - (5 * ageVal) + 5 - case .female: - bmr = (10 * weightKg) + (6.25 * heightCmVal) - (5 * ageVal) - 161 - } + let bmr: Double = sex == .male + ? (10 * weightKg) + (6.25 * heightCmVal) - (5 * ageVal) + 5 + : (10 * weightKg) + (6.25 * heightCmVal) - (5 * ageVal) - 161 let tdee = bmr * activityLevel.multiplier let targetCalories = max(1200, tdee + physiqGoal.calorieAdjustment) @@ -79,15 +63,9 @@ final class OnboardingState: ObservableObject { let fat = (targetCalories * fatPct) / 9 let carbs = max((targetCalories - protein * 4 - fat * 9) / 4, 0) - return TDEEResult( - bmr: bmr, - tdee: tdee, - targetCalories: targetCalories, - protein: protein, - carbs: carbs, - fat: fat, - adjustmentCalories: physiqGoal.calorieAdjustment - ) + return TDEEResult(bmr: bmr, tdee: tdee, targetCalories: targetCalories, + protein: protein, carbs: carbs, fat: fat, + adjustmentCalories: physiqGoal.calorieAdjustment) } } @@ -106,7 +84,6 @@ struct OnboardingSurveyView: View { NavigationStack { ZStack { Theme.surface.ignoresSafeArea() - Group { switch onboarding.step { case .welcome: WelcomeStep(onboarding: onboarding) @@ -120,7 +97,7 @@ struct OnboardingSurveyView: View { } .transition(.asymmetric( insertion: .move(edge: .trailing).combined(with: .opacity), - removal: .move(edge: .leading).combined(with: .opacity) + removal: .move(edge: .leading).combined(with: .opacity) )) .id(onboarding.step) } @@ -128,11 +105,8 @@ struct OnboardingSurveyView: View { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { if onboarding.step != .welcome && onboarding.step != .result { - Button("Skip all") { - withAnimation { onboarding.step = .result } - } - .foregroundColor(Theme.textSecondary) - .font(.subheadline) + Button("Skip all") { withAnimation { onboarding.step = .result } } + .foregroundColor(Theme.textSecondary).font(.subheadline) } } } @@ -143,8 +117,7 @@ struct OnboardingSurveyView: View { // MARK: - Progress Bar private struct OnboardingProgressBar: View { - let progress: Double // 0.0 – 1.0 - + let progress: Double var body: some View { GeometryReader { geo in ZStack(alignment: .leading) { @@ -158,7 +131,7 @@ private struct OnboardingProgressBar: View { } } -// MARK: - Step Shell (shared chrome) +// MARK: - Step Shell private struct StepShell: View { let stepLabel: String @@ -169,7 +142,6 @@ private struct StepShell: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { - // Back + progress HStack(spacing: 12) { Button(action: onBack) { Image(systemName: "chevron.left") @@ -181,9 +153,7 @@ private struct StepShell: View { .padding(.bottom, 6) Text(stepLabel) - .font(.caption) - .foregroundColor(Theme.textSecondary) - .padding(.bottom, 16) + .font(.caption).foregroundColor(Theme.textSecondary).padding(.bottom, 16) content() } @@ -196,53 +166,26 @@ private struct StepShell: View { private struct WelcomeStep: View { @ObservedObject var onboarding: OnboardingState - var body: some View { VStack(alignment: .leading, spacing: 0) { Spacer(minLength: 48) - Image(systemName: "bolt.heart.fill") - .font(.system(size: 44)) - .foregroundColor(Theme.primary) - .padding(.bottom, 20) - + .font(.system(size: 44)).foregroundColor(Theme.primary).padding(.bottom, 20) Text("Welcome to MealEngine") - .font(.largeTitle.bold()) - .foregroundColor(Theme.textPrimary) - .padding(.bottom, 12) - + .font(.largeTitle.bold()).foregroundColor(Theme.textPrimary).padding(.bottom, 12) Text("Answer a few quick questions to set up your personalised calorie and macro targets. It takes about 60 seconds.") - .font(.body) - .foregroundColor(Theme.textSecondary) - .fixedSize(horizontal: false, vertical: true) - .padding(.bottom, 40) - - Button { - withAnimation { onboarding.step = .sex } - } label: { - Text("Get started") - .bold() - .frame(maxWidth: .infinity) - .padding() - .background(Theme.primary) - .foregroundColor(.white) - .cornerRadius(14) + .font(.body).foregroundColor(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true).padding(.bottom, 40) + Button { withAnimation { onboarding.step = .sex } } label: { + Text("Get started").bold().frame(maxWidth: .infinity).padding() + .background(Theme.primary).foregroundColor(.white).cornerRadius(14) } .padding(.bottom, 12) - - Button { - withAnimation { onboarding.step = .result } - } label: { - Text("Skip for now") - .frame(maxWidth: .infinity) - .padding() + Button { withAnimation { onboarding.step = .result } } label: { + Text("Skip for now").frame(maxWidth: .infinity).padding() .foregroundColor(Theme.textSecondary) - .overlay( - RoundedRectangle(cornerRadius: 14) - .stroke(Theme.textSecondary.opacity(0.3), lineWidth: 1) - ) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(Theme.textSecondary.opacity(0.3), lineWidth: 1)) } - Spacer() } .padding(24) @@ -253,70 +196,43 @@ private struct WelcomeStep: View { private struct SexStep: View { @ObservedObject var onboarding: OnboardingState - var body: some View { - StepShell(stepLabel: "Step 1 of 4", progress: 0.25, + StepShell(stepLabel: "Step 1 of 5", progress: 0.20, onBack: { withAnimation { onboarding.step = .welcome } }) { - - Text("Biological sex") - .font(.title2.bold()) - .foregroundColor(Theme.textPrimary) - .padding(.bottom, 4) - + Text("Biological sex").font(.title2.bold()).foregroundColor(Theme.textPrimary).padding(.bottom, 4) Text("Used in the Mifflin-St Jeor BMR formula.") - .font(.subheadline) - .foregroundColor(Theme.textSecondary) - .padding(.bottom, 24) - + .font(.subheadline).foregroundColor(Theme.textSecondary).padding(.bottom, 24) HStack(spacing: 12) { ForEach(BiologicalSex.allCases) { s in - SexOptionCard( - label: s.rawValue, - icon: s == .male ? "person.fill" : "person.fill", - isSelected: onboarding.hasSex && onboarding.sex == s - ) { - onboarding.sex = s - onboarding.hasSex = true + SexOptionCard(label: s.rawValue, isSelected: onboarding.hasSex && onboarding.sex == s) { + onboarding.sex = s; onboarding.hasSex = true } } } .padding(.bottom, 32) - - StepActions( - canContinue: onboarding.hasSex, - continueLabel: "Continue", - onContinue: { withAnimation { onboarding.step = .body } }, - onSkip: { withAnimation { onboarding.step = .body } } - ) + StepActions(canContinue: onboarding.hasSex, continueLabel: "Continue", + onContinue: { withAnimation { onboarding.step = .body } }, + onSkip: { withAnimation { onboarding.step = .body } }) } } } private struct SexOptionCard: View { let label: String - let icon: String let isSelected: Bool let action: () -> Void - var body: some View { Button(action: action) { VStack(spacing: 8) { - Image(systemName: icon) - .font(.title2) - .foregroundColor(isSelected ? Theme.primary : Theme.textSecondary) - Text(label) - .font(.headline) - .foregroundColor(isSelected ? Theme.primary : Theme.textPrimary) + Image(systemName: "person.fill") + .font(.title2).foregroundColor(isSelected ? Theme.primary : Theme.textSecondary) + Text(label).font(.headline).foregroundColor(isSelected ? Theme.primary : Theme.textPrimary) } - .frame(maxWidth: .infinity) - .padding(.vertical, 20) - .background(isSelected ? Theme.primary.opacity(0.08) : Theme.surface) - .cornerRadius(14) - .overlay( - RoundedRectangle(cornerRadius: 14) - .stroke(isSelected ? Theme.primary : Theme.textSecondary.opacity(0.2), - lineWidth: isSelected ? 2 : 1) - ) + .frame(maxWidth: .infinity).padding(.vertical, 20) + .background(isSelected ? Theme.primary.opacity(0.08) : Theme.surface).cornerRadius(14) + .overlay(RoundedRectangle(cornerRadius: 14) + .stroke(isSelected ? Theme.primary : Theme.textSecondary.opacity(0.2), + lineWidth: isSelected ? 2 : 1)) } .buttonStyle(.plain) .animation(.easeInOut(duration: 0.15), value: isSelected) @@ -327,90 +243,58 @@ private struct SexOptionCard: View { private struct BodyStep: View { @ObservedObject var onboarding: OnboardingState - var body: some View { - StepShell(stepLabel: "Step 2 of 4", progress: 0.50, + StepShell(stepLabel: "Step 2 of 5", progress: 0.40, onBack: { withAnimation { onboarding.step = .sex } }) { - - Text("Body measurements") - .font(.title2.bold()) - .foregroundColor(Theme.textPrimary) - .padding(.bottom, 4) - + Text("Body measurements").font(.title2.bold()).foregroundColor(Theme.textPrimary).padding(.bottom, 4) Text("Your stats let us calculate your maintenance calories (TDEE).") - .font(.subheadline) - .foregroundColor(Theme.textSecondary) - .padding(.bottom, 24) + .font(.subheadline).foregroundColor(Theme.textSecondary).padding(.bottom, 24) - // Age MeasurementRow(label: "Age") { HStack { - TextField("e.g. 28", text: $onboarding.age) - .keyboardType(.numberPad) - .padding(10) - .background(Theme.primary.opacity(0.06)) - .cornerRadius(10) - .frame(maxWidth: 90) + TextField("e.g. 28", text: $onboarding.age).keyboardType(.numberPad) + .padding(10).background(Theme.primary.opacity(0.06)).cornerRadius(10).frame(maxWidth: 90) Text("yrs").foregroundColor(Theme.textSecondary).font(.caption) } } - // Weight MeasurementRow(label: "Weight") { VStack(alignment: .leading, spacing: 6) { - Picker("Unit", selection: $onboarding.weightUnit) { + Picker("", selection: $onboarding.weightUnit) { ForEach(WeightUnit.allCases) { u in Text(u.rawValue).tag(u) } } - .pickerStyle(.segmented) - .frame(maxWidth: 120) - + .pickerStyle(.segmented).frame(maxWidth: 120) HStack { - TextField(onboarding.weightUnit == .kg ? "e.g. 75" : "e.g. 165", - text: $onboarding.weightVal) - .keyboardType(.decimalPad) - .padding(10) - .background(Theme.primary.opacity(0.06)) - .cornerRadius(10) - Text(onboarding.weightUnit.rawValue) - .foregroundColor(Theme.textSecondary).font(.caption) + TextField(onboarding.weightUnit == .kg ? "e.g. 75" : "e.g. 165", text: $onboarding.weightVal) + .keyboardType(.decimalPad).padding(10) + .background(Theme.primary.opacity(0.06)).cornerRadius(10) + Text(onboarding.weightUnit.rawValue).foregroundColor(Theme.textSecondary).font(.caption) } } } - // Height MeasurementRow(label: "Height") { VStack(alignment: .leading, spacing: 6) { - Picker("Unit", selection: $onboarding.heightUnit) { + Picker("", selection: $onboarding.heightUnit) { ForEach(HeightUnit.allCases) { u in Text(u.rawValue).tag(u) } } - .pickerStyle(.segmented) - .frame(maxWidth: 120) - + .pickerStyle(.segmented).frame(maxWidth: 120) if onboarding.heightUnit == .cm { HStack { - TextField("e.g. 175", text: $onboarding.heightCm) - .keyboardType(.decimalPad) - .padding(10) - .background(Theme.primary.opacity(0.06)) - .cornerRadius(10) + TextField("e.g. 175", text: $onboarding.heightCm).keyboardType(.decimalPad) + .padding(10).background(Theme.primary.opacity(0.06)).cornerRadius(10) Text("cm").foregroundColor(Theme.textSecondary).font(.caption) } } else { HStack(spacing: 10) { HStack { - TextField("5", text: $onboarding.heightFt) - .keyboardType(.numberPad) - .padding(10) - .background(Theme.primary.opacity(0.06)) - .cornerRadius(10) + TextField("5", text: $onboarding.heightFt).keyboardType(.numberPad) + .padding(10).background(Theme.primary.opacity(0.06)).cornerRadius(10) Text("ft").foregroundColor(Theme.textSecondary).font(.caption) } HStack { - TextField("10", text: $onboarding.heightIn) - .keyboardType(.numberPad) - .padding(10) - .background(Theme.primary.opacity(0.06)) - .cornerRadius(10) + TextField("10", text: $onboarding.heightIn).keyboardType(.numberPad) + .padding(10).background(Theme.primary.opacity(0.06)).cornerRadius(10) Text("in").foregroundColor(Theme.textSecondary).font(.caption) } } @@ -419,13 +303,9 @@ private struct BodyStep: View { } Spacer(minLength: 24) - - StepActions( - canContinue: onboarding.isBodyValid, - continueLabel: "Continue", - onContinue: { withAnimation { onboarding.step = .activity } }, - onSkip: { withAnimation { onboarding.step = .activity } } - ) + StepActions(canContinue: onboarding.isBodyValid, continueLabel: "Continue", + onContinue: { withAnimation { onboarding.step = .activity } }, + onSkip: { withAnimation { onboarding.step = .activity } }) } } } @@ -433,12 +313,9 @@ private struct BodyStep: View { private struct MeasurementRow: View { let label: String @ViewBuilder let content: () -> Content - var body: some View { VStack(alignment: .leading, spacing: 8) { - Text(label) - .font(.subheadline) - .foregroundColor(Theme.textSecondary) + Text(label).font(.subheadline).foregroundColor(Theme.textSecondary) content() } .padding(.bottom, 16) @@ -449,67 +326,38 @@ private struct MeasurementRow: View { private struct ActivityStep: View { @ObservedObject var onboarding: OnboardingState - var body: some View { - StepShell(stepLabel: "Step 3 of 4", progress: 0.75, + StepShell(stepLabel: "Step 3 of 5", progress: 0.60, onBack: { withAnimation { onboarding.step = .body } }) { - - Text("Activity level") - .font(.title2.bold()) - .foregroundColor(Theme.textPrimary) - .padding(.bottom, 4) - + Text("Activity level").font(.title2.bold()).foregroundColor(Theme.textPrimary).padding(.bottom, 4) Text("Pick what best matches your average week.") - .font(.subheadline) - .foregroundColor(Theme.textSecondary) - .padding(.bottom, 20) - + .font(.subheadline).foregroundColor(Theme.textSecondary).padding(.bottom, 20) ForEach(ActivityLevel.allCases) { level in let isSelected = onboarding.hasActivity && onboarding.activityLevel == level Button { - onboarding.activityLevel = level - onboarding.hasActivity = true + onboarding.activityLevel = level; onboarding.hasActivity = true } label: { HStack(spacing: 14) { - Circle() - .fill(isSelected ? Theme.primary : Theme.textSecondary.opacity(0.2)) + Circle().fill(isSelected ? Theme.primary : Theme.textSecondary.opacity(0.2)) .frame(width: 12, height: 12) - VStack(alignment: .leading, spacing: 2) { - Text(level.rawValue) - .font(.subheadline.bold()) - .foregroundColor(Theme.textPrimary) - Text(level.description) - .font(.caption) - .foregroundColor(Theme.textSecondary) + Text(level.rawValue).font(.subheadline.bold()).foregroundColor(Theme.textPrimary) + Text(level.description).font(.caption).foregroundColor(Theme.textSecondary) } Spacer() - if isSelected { - Image(systemName: "checkmark") - .font(.caption.bold()) - .foregroundColor(Theme.primary) - } + if isSelected { Image(systemName: "checkmark").font(.caption.bold()).foregroundColor(Theme.primary) } } .padding(14) - .background(isSelected ? Theme.primary.opacity(0.06) : Color.clear) - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(isSelected ? Theme.primary.opacity(0.4) : Theme.textSecondary.opacity(0.15), - lineWidth: isSelected ? 1.5 : 1) - ) + .background(isSelected ? Theme.primary.opacity(0.06) : Color.clear).cornerRadius(12) + .overlay(RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? Theme.primary.opacity(0.4) : Theme.textSecondary.opacity(0.15), + lineWidth: isSelected ? 1.5 : 1)) } - .buttonStyle(.plain) - .animation(.easeInOut(duration: 0.15), value: isSelected) - .padding(.bottom, 8) + .buttonStyle(.plain).animation(.easeInOut(duration: 0.15), value: isSelected).padding(.bottom, 8) } - - StepActions( - canContinue: onboarding.hasActivity, - continueLabel: "Continue", - onContinue: { withAnimation { onboarding.step = .goal } }, - onSkip: { withAnimation { onboarding.step = .goal } } - ) + StepActions(canContinue: onboarding.hasActivity, continueLabel: "Continue", + onContinue: { withAnimation { onboarding.step = .goal } }, + onSkip: { withAnimation { onboarding.step = .goal } }) } } } @@ -519,133 +367,92 @@ private struct ActivityStep: View { private struct GoalStep: View { @ObservedObject var onboarding: OnboardingState + // Inline emoji lookup — avoids depending on PhysiqGoal.emoji scope from TDEECalculatorView + private func emoji(for goal: PhysiqGoal) -> String { + switch goal { + case .cutting: return "🔥" + case .maintaining: return "⚖️" + case .bulking: return "💪" + } + } + var body: some View { - StepShell(stepLabel: "Step 4 of 4", progress: 1.0, + StepShell(stepLabel: "Step 4 of 5", progress: 0.80, onBack: { withAnimation { onboarding.step = .activity } }) { - - Text("What's your goal?") - .font(.title2.bold()) - .foregroundColor(Theme.textPrimary) - .padding(.bottom, 4) - + Text("What's your goal?").font(.title2.bold()).foregroundColor(Theme.textPrimary).padding(.bottom, 4) Text("This sets your calorie target and macro split.") - .font(.subheadline) - .foregroundColor(Theme.textSecondary) - .padding(.bottom, 20) - + .font(.subheadline).foregroundColor(Theme.textSecondary).padding(.bottom, 20) ForEach(PhysiqGoal.allCases) { goal in let isSelected = onboarding.hasGoal && onboarding.physiqGoal == goal Button { - onboarding.physiqGoal = goal - onboarding.hasGoal = true + onboarding.physiqGoal = goal; onboarding.hasGoal = true } label: { HStack(spacing: 14) { - Text(goal.emoji).font(.title2) - + Text(emoji(for: goal)).font(.title2) VStack(alignment: .leading, spacing: 2) { - Text(goal.rawValue) - .font(.headline) + Text(goal.rawValue).font(.headline) .foregroundColor(isSelected ? .white : Theme.textPrimary) - Text(goal.description) - .font(.caption) + Text(goal.description).font(.caption) .foregroundColor(isSelected ? .white.opacity(0.8) : Theme.textSecondary) } Spacer() - if isSelected { - Image(systemName: "checkmark") - .font(.caption.bold()) - .foregroundColor(.white) - } + if isSelected { Image(systemName: "checkmark").font(.caption.bold()).foregroundColor(.white) } } .padding(16) - .background(isSelected ? goal.color : goal.color.opacity(0.07)) - .cornerRadius(14) - .overlay( - RoundedRectangle(cornerRadius: 14) - .stroke(goal.color.opacity(isSelected ? 0 : 0.3), lineWidth: 1) - ) + .background(isSelected ? goal.color : goal.color.opacity(0.07)).cornerRadius(14) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(goal.color.opacity(isSelected ? 0 : 0.3), lineWidth: 1)) } - .buttonStyle(.plain) - .animation(.easeInOut(duration: 0.15), value: isSelected) - .padding(.bottom, 10) + .buttonStyle(.plain).animation(.easeInOut(duration: 0.15), value: isSelected).padding(.bottom, 10) } - - StepActions( - canContinue: onboarding.hasGoal, - continueLabel: "Calculate my goals", - onContinue: { withAnimation { onboarding.step = .dietary } }, - onSkip: { withAnimation { onboarding.step = .dietary } } - ) + StepActions(canContinue: onboarding.hasGoal, continueLabel: "Continue", + onContinue: { withAnimation { onboarding.step = .dietary } }, + onSkip: { withAnimation { onboarding.step = .dietary } }) } } } +// MARK: - Step 5: Dietary Preferences -// MARK: - Step 5: Diet private struct DietaryStep: View { @ObservedObject var onboarding: OnboardingState - private let columns = [GridItem(.flexible()), GridItem(.flexible())] var body: some View { - StepShell(stepLabel: "Almost done", progress: 1.0, + StepShell(stepLabel: "Step 5 of 5", progress: 1.0, onBack: { withAnimation { onboarding.step = .goal } }) { - - Text("Dietary preferences") - .font(.title2.bold()) - .foregroundColor(Theme.textPrimary) - .padding(.bottom, 4) - - Text("Select any that apply. This filters your food recommendations. You can change these any time in Settings.") - .font(.subheadline) - .foregroundColor(Theme.textSecondary) - .padding(.bottom, 20) + Text("Dietary preferences").font(.title2.bold()).foregroundColor(Theme.textPrimary).padding(.bottom, 4) + Text("Select any that apply. Used to filter smart meal recommendations. You can change these in Settings any time.") + .font(.subheadline).foregroundColor(Theme.textSecondary).padding(.bottom, 20) LazyVGrid(columns: columns, spacing: 10) { - ForEach(DietaryRestriction.allCases) { restriction in - let isOn = onboarding.dietaryProfile.restrictions.contains(restriction) + ForEach(DietaryRestriction.allCases) { r in + let isOn = onboarding.dietaryProfile.restrictions.contains(r) Button { - if isOn { - onboarding.dietaryProfile.restrictions.remove(restriction) - } else { - onboarding.dietaryProfile.restrictions.insert(restriction) - } + if isOn { onboarding.dietaryProfile.restrictions.remove(r) } + else { onboarding.dietaryProfile.restrictions.insert(r) } } label: { HStack(spacing: 8) { - Image(systemName: restriction.icon) - .font(.caption) + Image(systemName: r.icon).font(.caption) .foregroundColor(isOn ? Theme.primary : Theme.textSecondary) - Text(restriction.rawValue) - .font(.subheadline) + Text(r.rawValue).font(.subheadline) .foregroundColor(isOn ? Theme.primary : Theme.textPrimary) Spacer() - if isOn { - Image(systemName: "checkmark") - .font(.caption.bold()) - .foregroundColor(Theme.primary) - } + if isOn { Image(systemName: "checkmark").font(.caption.bold()).foregroundColor(Theme.primary) } } .padding(12) - .background(isOn ? Theme.primary.opacity(0.07) : Theme.surface) - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(isOn ? Theme.primary.opacity(0.4) : Theme.textSecondary.opacity(0.2), - lineWidth: isOn ? 1.5 : 1) - ) + .background(isOn ? Theme.primary.opacity(0.07) : Theme.surface).cornerRadius(12) + .overlay(RoundedRectangle(cornerRadius: 12) + .stroke(isOn ? Theme.primary.opacity(0.4) : Theme.textSecondary.opacity(0.2), + lineWidth: isOn ? 1.5 : 1)) } - .buttonStyle(.plain) - .animation(.easeInOut(duration: 0.15), value: isOn) + .buttonStyle(.plain).animation(.easeInOut(duration: 0.15), value: isOn) } } .padding(.bottom, 24) - StepActions( - canContinue: true, - continueLabel: "Continue", - onContinue: { withAnimation { onboarding.step = .result } }, - onSkip: { withAnimation { onboarding.step = .result } } - ) + StepActions(canContinue: true, continueLabel: "Continue", + onContinue: { withAnimation { onboarding.step = .result } }, + onSkip: { withAnimation { onboarding.step = .result } }) } } } @@ -660,92 +467,63 @@ private struct ResultStep: View { private var result: TDEEResult? { onboarding.calculate() } + // Inline emoji — same reason as GoalStep + private func emoji(for goal: PhysiqGoal) -> String { + switch goal { + case .cutting: return "🔥"; case .maintaining: return "⚖️"; case .bulking: return "💪" + } + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { if let res = result { - // Header VStack(alignment: .leading, spacing: 4) { - Text("Your goals are set") - .font(.title2.bold()) - .foregroundColor(Theme.textPrimary) + Text("Your goals are set").font(.title2.bold()).foregroundColor(Theme.textPrimary) Text("Based on your stats and the \(onboarding.physiqGoal.rawValue.lowercased()) goal.") - .font(.subheadline) - .foregroundColor(Theme.textSecondary) + .font(.subheadline).foregroundColor(Theme.textSecondary) } - // Calorie hero card VStack(spacing: 16) { HStack { VStack(alignment: .leading, spacing: 4) { - Text("Daily target") - .font(.caption) - .foregroundColor(Theme.textSecondary) - Text("\(Int(res.targetCalories)) kcal") - .font(.largeTitle.bold()) - .foregroundColor(Theme.primary) + Text("Daily target").font(.caption).foregroundColor(Theme.textSecondary) + Text("\(Int(res.targetCalories)) kcal").font(.largeTitle.bold()).foregroundColor(Theme.primary) } Spacer() - Text(onboarding.physiqGoal.emoji).font(.system(size: 40)) + Text(emoji(for: onboarding.physiqGoal)).font(.system(size: 40)) } - - // Macro bar MacroBarView(protein: res.protein, carbs: res.carbs, fat: res.fat, targetCalories: res.targetCalories) - - // Macro pills HStack(spacing: 10) { - MacroResultPill(label: "Protein", value: "\(Int(res.protein))g", - color: Theme.success) - MacroResultPill(label: "Carbs", value: "\(Int(res.carbs))g", - color: Theme.primary) - MacroResultPill(label: "Fat", value: "\(Int(res.fat))g", - color: Theme.warning) + MacroResultPill(label: "Protein", value: "\(Int(res.protein))g", color: Theme.success) + MacroResultPill(label: "Carbs", value: "\(Int(res.carbs))g", color: Theme.primary) + MacroResultPill(label: "Fat", value: "\(Int(res.fat))g", color: Theme.warning) } - Divider() - - // TDEE row HStack { Label("TDEE (maintenance)", systemImage: "flame.fill") - .font(.caption) - .foregroundColor(Theme.textSecondary) + .font(.caption).foregroundColor(Theme.textSecondary) Spacer() - Text("\(Int(res.tdee)) kcal") - .font(.caption.bold()) - .foregroundColor(Theme.textPrimary) + Text("\(Int(res.tdee)) kcal").font(.caption.bold()).foregroundColor(Theme.textPrimary) } } - .padding(16) - .background(Theme.surface) - .cornerRadius(18) - .overlay( - RoundedRectangle(cornerRadius: 18) - .stroke(onboarding.physiqGoal.color.opacity(0.3), lineWidth: 1.5) - ) + .padding(16).background(Theme.surface).cornerRadius(18) + .overlay(RoundedRectangle(cornerRadius: 18) + .stroke(onboarding.physiqGoal.color.opacity(0.3), lineWidth: 1.5)) .shadow(color: onboarding.physiqGoal.color.opacity(0.08), radius: 10, y: 4) - // Explanation Text(onboarding.physiqGoal.explanation) - .font(.caption) - .foregroundColor(Theme.textSecondary) - .padding(12) - .background(onboarding.physiqGoal.color.opacity(0.07)) - .cornerRadius(10) + .font(.caption).foregroundColor(Theme.textSecondary) + .padding(12).background(onboarding.physiqGoal.color.opacity(0.07)).cornerRadius(10) - // Apply button - Button { - showApplyConfirm = true - } label: { + Button { showApplyConfirm = true } label: { HStack { Image(systemName: "checkmark.circle.fill") Text("Apply these goals").bold() } - .frame(maxWidth: .infinity) - .padding() - .background(onboarding.physiqGoal.color) - .foregroundColor(.white) - .cornerRadius(14) + .frame(maxWidth: .infinity).padding() + .background(onboarding.physiqGoal.color).foregroundColor(.white).cornerRadius(14) } .alert("Apply these goals?", isPresented: $showApplyConfirm) { Button("Apply") { applyAndDismiss(res) } @@ -755,38 +533,19 @@ private struct ResultStep: View { } } else { - // Skipped / insufficient data VStack(spacing: 16) { - Image(systemName: "checkmark.circle") - .font(.system(size: 44)) - .foregroundColor(Theme.primary) - - Text("All done") - .font(.title2.bold()) - .foregroundColor(Theme.textPrimary) - + Image(systemName: "checkmark.circle").font(.system(size: 44)).foregroundColor(Theme.primary) + Text("All done").font(.title2.bold()).foregroundColor(Theme.textPrimary) Text("You can set your calorie and macro targets manually in Settings whenever you're ready.") - .font(.subheadline) - .foregroundColor(Theme.textSecondary) - .multilineTextAlignment(.center) + .font(.subheadline).foregroundColor(Theme.textSecondary).multilineTextAlignment(.center) } - .frame(maxWidth: .infinity) - .padding(32) + .frame(maxWidth: .infinity).padding(32) } - // Skip / go to app - Button { - markOnboardingComplete() - dismiss() - } label: { + Button { markOnboardingComplete(); dismiss() } label: { Text(result != nil ? "Maybe later" : "Go to the app") - .frame(maxWidth: .infinity) - .padding() - .foregroundColor(Theme.textSecondary) - .overlay( - RoundedRectangle(cornerRadius: 14) - .stroke(Theme.textSecondary.opacity(0.3), lineWidth: 1) - ) + .frame(maxWidth: .infinity).padding().foregroundColor(Theme.textSecondary) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(Theme.textSecondary.opacity(0.3), lineWidth: 1)) } } .padding(20) @@ -814,95 +573,62 @@ private struct ResultStep: View { // MARK: - Reusable sub-views private struct MacroBarView: View { - let protein: Double - let carbs: Double - let fat: Double - let targetCalories: Double - + let protein: Double; let carbs: Double; let fat: Double; let targetCalories: Double var body: some View { let total = protein * 4 + carbs * 4 + fat * 9 let pPct = total > 0 ? (protein * 4) / total : 0 let cPct = total > 0 ? (carbs * 4) / total : 0 let fPct = total > 0 ? (fat * 9) / total : 0 - return GeometryReader { geo in HStack(spacing: 2) { - Capsule().fill(Theme.success) - .frame(width: geo.size.width * CGFloat(pPct)) - Capsule().fill(Theme.primary) - .frame(width: geo.size.width * CGFloat(cPct)) - Capsule().fill(Theme.warning) - .frame(width: geo.size.width * CGFloat(fPct)) + Capsule().fill(Theme.success).frame(width: geo.size.width * CGFloat(pPct)) + Capsule().fill(Theme.primary).frame(width: geo.size.width * CGFloat(cPct)) + Capsule().fill(Theme.warning).frame(width: geo.size.width * CGFloat(fPct)) } } - .frame(height: 8) - .clipShape(Capsule()) + .frame(height: 8).clipShape(Capsule()) } } private struct MacroResultPill: View { - let label: String - let value: String - let color: Color - + let label: String; let value: String; let color: Color var body: some View { VStack(spacing: 6) { Circle().fill(color).frame(width: 8, height: 8) - Text(value) - .font(.subheadline.bold()) - .foregroundColor(Theme.textPrimary) - Text(label) - .font(.caption2) - .foregroundColor(Theme.textSecondary) + Text(value).font(.subheadline.bold()).foregroundColor(Theme.textPrimary) + Text(label).font(.caption2).foregroundColor(Theme.textSecondary) } - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background(color.opacity(0.08)) - .cornerRadius(12) + .frame(maxWidth: .infinity).padding(.vertical, 10) + .background(color.opacity(0.08)).cornerRadius(12) } } private struct StepActions: View { - let canContinue: Bool - let continueLabel: String - let onContinue: () -> Void - let onSkip: () -> Void - + let canContinue: Bool; let continueLabel: String + let onContinue: () -> Void; let onSkip: () -> Void var body: some View { HStack(spacing: 10) { Button(action: onContinue) { - Text(continueLabel) - .bold() - .frame(maxWidth: .infinity) - .padding() + Text(continueLabel).bold().frame(maxWidth: .infinity).padding() .background(canContinue ? Theme.primary : Theme.textSecondary.opacity(0.3)) - .foregroundColor(.white) - .cornerRadius(14) + .foregroundColor(.white).cornerRadius(14) } .disabled(!canContinue) - Button(action: onSkip) { - Text("Skip") - .padding() - .foregroundColor(Theme.textSecondary) - .overlay( - RoundedRectangle(cornerRadius: 14) - .stroke(Theme.textSecondary.opacity(0.3), lineWidth: 1) - ) + Text("Skip").padding().foregroundColor(Theme.textSecondary) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(Theme.textSecondary.opacity(0.3), lineWidth: 1)) } } .padding(.top, 8) } } +// MARK: - UserDefaults extension + extension UserDefaults { - var hasCompletedOnboarding: Bool { - bool(forKey: "hasCompletedOnboarding") - } + var hasCompletedOnboarding: Bool { bool(forKey: "hasCompletedOnboarding") } } -// MARK: - Preview - #Preview { OnboardingSurveyView(vm: MealPlannerViewModel()) } diff --git a/Views/Screens/PlannerView.swift b/Views/Screens/PlannerView.swift index d594aff..aac1a78 100644 --- a/Views/Screens/PlannerView.swift +++ b/Views/Screens/PlannerView.swift @@ -1,20 +1,22 @@ -// PURPOSE: Provides the main meal planning interface with free food search -// and a smart recommendations card driven by dietary preferences + knapsack. -// HLFR: The system shall allow users to plan and log meals +// PURPOSE: Main meal planning interface. +// Search card: search freely, tap to select, "Add to Meal" puts items in the +// basket. Search again for more. Smart Picks toggle runs knapsack on results. +// Basket shows everything queued so far; "Save Meal" logs it all at once. import SwiftUI struct PlannerView: View { @ObservedObject var vm: MealPlannerViewModel @State private var showSaveConfirmation = false - @State private var showSaveRecommendedConfirmation = false var body: some View { ScrollView { VStack(spacing: 20) { todayProgressCard - freeSearchCard - recommendationsCard + searchCard + if !vm.pendingMealFoods.isEmpty { + pendingMealCard + } } .padding() } @@ -23,20 +25,11 @@ struct PlannerView: View { .alert("Meal Saved!", isPresented: $showSaveConfirmation) { Button("OK", role: .cancel) { } } message: { - Text("Your meal has been added to today's log.") - } - .alert("Meal Saved!", isPresented: $showSaveRecommendedConfirmation) { - Button("OK", role: .cancel) { } - } message: { - Text("Your recommended meal has been added to today's log.") + Text("Added to today's log.") } - .alert( - "Input Error", - isPresented: Binding( - get: { vm.inputErrorMessage != nil }, - set: { _ in vm.inputErrorMessage = nil } - ) - ) { + .alert("Error", + isPresented: Binding(get: { vm.inputErrorMessage != nil }, + set: { _ in vm.inputErrorMessage = nil })) { Button("OK", role: .cancel) { } } message: { Text(vm.inputErrorMessage ?? "") @@ -49,36 +42,30 @@ struct PlannerView: View { VStack(spacing: 12) { HStack { Image(systemName: "calendar.circle.fill") - .font(.title2) - .foregroundColor(Theme.primary) + .font(.title2).foregroundColor(Theme.primary) Text("Today's Progress") - .font(.headline) - .foregroundColor(Theme.textPrimary) + .font(.headline).foregroundColor(Theme.textPrimary) Spacer() - let progress = vm.currentCalories / max(vm.goals.calories, 1) - Text("\(Int(progress * 100))%") - .font(.title3).bold() - .foregroundColor(progressColor(progress)) + let pct = vm.currentCalories / max(vm.goals.calories, 1) + Text("\(Int(min(pct, 1) * 100))%") + .font(.title3).bold().foregroundColor(progressColor(pct)) } GeometryReader { geo in ZStack(alignment: .leading) { Capsule().fill(Theme.textSecondary.opacity(0.15)) - let progress = min(vm.currentCalories / max(vm.goals.calories, 1), 1.0) - Capsule() - .fill(progressColor(progress)) - .frame(width: geo.size.width * CGFloat(progress)) + let w = min(vm.currentCalories / max(vm.goals.calories, 1), 1.0) + Capsule().fill(progressColor(w)).frame(width: geo.size.width * CGFloat(w)) } } - .frame(height: 12) + .frame(height: 10) HStack(spacing: 12) { - macroStat(title: "Calories", current: Int(vm.currentCalories), goal: Int(vm.goals.calories), color: Theme.primary) - macroStat(title: "Protein", current: Int(vm.currentProtein), goal: Int(vm.goals.protein), color: Theme.success) - macroStat(title: "Carbs", current: Int(vm.currentCarbs), goal: Int(vm.goals.carbs), color: Theme.accent) - macroStat(title: "Fat", current: Int(vm.currentFat), goal: Int(vm.goals.fat), color: Theme.warning) + macroStat("Calories", cur: Int(vm.currentCalories), goal: Int(vm.goals.calories), color: Theme.primary, unit: "kcal") + macroStat("Protein", cur: Int(vm.currentProtein), goal: Int(vm.goals.protein), color: Theme.success, unit: "g") + macroStat("Carbs", cur: Int(vm.currentCarbs), goal: Int(vm.goals.carbs), color: Theme.accent, unit: "g") + macroStat("Fat", cur: Int(vm.currentFat), goal: Int(vm.goals.fat), color: Theme.warning, unit: "g") } - .font(.caption) } .padding() .background(Theme.surface) @@ -87,63 +74,82 @@ struct PlannerView: View { .shadow(color: .black.opacity(0.05), radius: 8, y: 4) } - private func macroStat(title: String, current: Int, goal: Int, color: Color) -> some View { - VStack(spacing: 4) { - Text(title).foregroundColor(Theme.textSecondary).font(.caption2) - Text("\(current)").bold().foregroundColor(color).font(.subheadline) - Text("/ \(goal)").foregroundColor(Theme.textSecondary.opacity(0.7)).font(.caption2) + private func macroStat(_ title: String, cur: Int, goal: Int, color: Color, unit: String) -> some View { + VStack(spacing: 3) { + Text(title).font(.caption2).foregroundColor(Theme.textSecondary) + Text("\(cur)").bold().foregroundColor(color).font(.subheadline) + Text("/ \(goal)\(unit)").font(.caption2).foregroundColor(Theme.textSecondary.opacity(0.7)) } .frame(maxWidth: .infinity) } - private func progressColor(_ progress: Double) -> Color { - switch progress { - case ..<0.60: return Theme.textSecondary - case ..<1.00: return Theme.primary - default: return Theme.success - } + private func progressColor(_ p: Double) -> Color { + p < 0.6 ? Theme.textSecondary : p < 1.0 ? Theme.primary : Theme.success } - // MARK: - Free Search Card + // MARK: - Search Card - private var freeSearchCard: some View { - VStack(spacing: 12) { + private var searchCard: some View { + VStack(spacing: 14) { + + // Header + Smart Picks toggle HStack(spacing: 10) { - Image(systemName: "magnifyingglass.circle.fill") - .font(.title3) - .foregroundColor(Theme.primary) + Image(systemName: vm.showSmartPicks ? "sparkles" : "magnifyingglass.circle.fill") + .font(.title3).foregroundColor(Theme.primary) + VStack(alignment: .leading, spacing: 2) { - Text("Search Foods") - .font(.headline) - .foregroundColor(Theme.textPrimary) - Text("Add any food directly to your meal") - .font(.caption) - .foregroundColor(Theme.textSecondary) + Text(vm.showSmartPicks ? "Smart Picks" : "Search Foods") + .font(.headline).foregroundColor(Theme.textPrimary) + Text(vm.showSmartPicks ? dietSummary : "Search and add foods to your meal") + .font(.caption).foregroundColor(Theme.textSecondary).lineLimit(1) } Spacer() + + VStack(spacing: 2) { + Toggle("", isOn: $vm.showSmartPicks) + .labelsHidden() + // iOS 17+ zero-parameter form — fixes deprecation warning + .onChange(of: vm.showSmartPicks) { + vm.refreshSmartPicks() + } + Text("Smart\nPicks") + .font(.caption2).foregroundColor(Theme.textSecondary).multilineTextAlignment(.center) + } } - // Optional calorie limit - HStack { - Image(systemName: "flame.fill").foregroundColor(Theme.primary) - TextField("Calorie limit (optional)", text: $vm.calorieLimit) - .keyboardType(.decimalPad) - .foregroundColor(Theme.textPrimary) + // Diet badge strip + if vm.showSmartPicks && !vm.dietaryProfile.restrictions.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(Array(vm.dietaryProfile.restrictions)) { r in + HStack(spacing: 4) { + Image(systemName: r.icon).font(.caption2) + Text(r.rawValue).font(.caption2) + } + .padding(.horizontal, 8).padding(.vertical, 4) + .background(Theme.primary.opacity(0.1)) + .foregroundColor(Theme.primary).cornerRadius(8) + } + } + } } - .padding() - .background(Theme.surface) - .cornerRadius(12) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(Theme.textSecondary.opacity(0.2), lineWidth: 1)) - // Query + // Search bar HStack { Image(systemName: "magnifyingglass").foregroundColor(Theme.primary) - TextField("Search food (e.g. chicken, rice)", text: $vm.query) + TextField(vm.showSmartPicks ? "Search for recommendations..." : "Search food (e.g. chicken)", + text: $vm.query) .foregroundColor(Theme.textPrimary) .submitLabel(.search) .onSubmit { vm.fetchFood() } + if !vm.query.isEmpty { - Button { vm.query = "" } label: { + Button { + vm.query = "" + vm.searchResults = [] + vm.smartPickResults = [] + vm.selectedFoodIDs = [] + } label: { Image(systemName: "xmark.circle.fill").foregroundColor(Theme.textSecondary) } } @@ -151,271 +157,230 @@ struct PlannerView: View { .padding() .background(Theme.surface) .cornerRadius(12) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(Theme.textSecondary.opacity(0.2), lineWidth: 1)) + .overlay(RoundedRectangle(cornerRadius: 12) + .stroke(vm.showSmartPicks ? Theme.primary.opacity(0.4) : Theme.textSecondary.opacity(0.2), lineWidth: 1)) - // Search + Save buttons + // Search + Add to Meal buttons HStack(spacing: 12) { Button { vm.fetchFood() } label: { HStack { if vm.isLoading { ProgressView().tint(.white) } - else { Image(systemName: "arrow.down.circle.fill") } - Text(vm.isLoading ? "Searching..." : "Search Foods").bold() + else { Image(systemName: vm.showSmartPicks ? "sparkle.magnifyingglass" : "magnifyingglass") } + Text(vm.isLoading ? "Searching..." : "Search").bold() } - .frame(maxWidth: .infinity) - .padding() + .frame(maxWidth: .infinity).padding() .background(vm.isLoading ? Color.gray : Theme.primary) - .foregroundColor(.white) - .cornerRadius(12) + .foregroundColor(.white).cornerRadius(12) } .disabled(vm.isLoading) Button { - vm.saveSelectedFoods() - showSaveConfirmation = true + vm.addSelectionToMeal() } label: { HStack { - Image(systemName: "checkmark.circle.fill") - Text("Save").bold() + Image(systemName: "plus.circle.fill") + Text("Add to Meal\(vm.selectedFoodIDs.isEmpty ? "" : " (\(vm.selectedFoodIDs.count))")").bold() } - .frame(maxWidth: .infinity) - .padding() - .background(vm.selectedFoodIDs.isEmpty ? Theme.textSecondary.opacity(0.3) : Theme.success) - .foregroundColor(.white) - .cornerRadius(12) + .frame(maxWidth: .infinity).padding() + .background(vm.selectedFoodIDs.isEmpty ? Theme.textSecondary.opacity(0.3) : Theme.primary.opacity(0.85)) + .foregroundColor(.white).cornerRadius(12) } .disabled(vm.selectedFoodIDs.isEmpty) } // Results - if !vm.searchResults.isEmpty { - freeSearchResults + if !vm.visibleResults.isEmpty { + resultsSection } } .padding() .background(Theme.surface) .cornerRadius(16) - .overlay(RoundedRectangle(cornerRadius: 16).stroke(Theme.textSecondary.opacity(0.2), lineWidth: 1)) - .shadow(color: .black.opacity(0.05), radius: 8, y: 4) + .overlay(RoundedRectangle(cornerRadius: 16) + .stroke(vm.showSmartPicks ? Theme.primary.opacity(0.3) : Theme.textSecondary.opacity(0.2), + lineWidth: vm.showSmartPicks ? 1.5 : 1)) + .shadow(color: vm.showSmartPicks ? Theme.primary.opacity(0.07) : .black.opacity(0.05), radius: 8, y: 4) } - private var freeSearchResults: some View { - VStack(spacing: 8) { - HStack { - Text("Results") - .font(.subheadline.bold()) - .foregroundColor(Theme.textPrimary) - Spacer() - Text("\(vm.searchResults.count) items") - .font(.caption) - .foregroundColor(Theme.textSecondary) - } - - // Totals for selection - if !vm.selectedFoodIDs.isEmpty { - HStack(spacing: 12) { - totalPill(title: "Cal", value: "\(Int(vm.totalCalories()))", color: Theme.primary) - totalPill(title: "Protein", value: "\(Int(vm.totalProtein()))g", color: Theme.success) - totalPill(title: "Carbs", value: "\(Int(vm.totalCarbs()))g", color: Theme.accent) - totalPill(title: "Fat", value: "\(Int(vm.totalFat()))g", color: Theme.warning) - } - } - - ForEach(vm.searchResults) { food in - foodCard( - food: food, - isSelected: vm.selectedFoodIDs.contains(food.id) - ) { - if vm.selectedFoodIDs.contains(food.id) { vm.selectedFoodIDs.remove(food.id) } - else { vm.selectedFoodIDs.insert(food.id) } - } - } - } + private var dietSummary: String { + let r = vm.dietaryProfile.restrictions + return r.isEmpty ? "Optimised to your calorie & macro goals" : r.map { $0.rawValue }.joined(separator: " · ") } - // MARK: - Smart Recommendations Card + // MARK: - Search Results - private var recommendationsCard: some View { - VStack(spacing: 12) { - HStack(spacing: 10) { - Image(systemName: "sparkles") - .font(.title3) - .foregroundColor(Theme.primary) - VStack(alignment: .leading, spacing: 2) { - Text("Smart Recommendations") - .font(.headline) - .foregroundColor(Theme.textPrimary) - Text(dietSummary) - .font(.caption) - .foregroundColor(Theme.textSecondary) + private var resultsSection: some View { + VStack(spacing: 10) { + HStack { + if vm.showSmartPicks { + Label("Recommended for you", systemImage: "sparkles") + .font(.subheadline.bold()).foregroundColor(Theme.textPrimary) + } else { + Text("Results").font(.subheadline.bold()).foregroundColor(Theme.textPrimary) } Spacer() + Text("\(vm.visibleResults.count) items").font(.caption).foregroundColor(Theme.textSecondary) } - // Optional keyword filter - HStack { - Image(systemName: "magnifyingglass").foregroundColor(Theme.primary) - TextField("Filter recommendations (e.g. chicken)", text: $vm.recommendedQuery) - .foregroundColor(Theme.textPrimary) - .submitLabel(.search) - .onSubmit { vm.fetchRecommendations() } - if !vm.recommendedQuery.isEmpty { - Button { - vm.recommendedQuery = "" - vm.fetchRecommendations() - } label: { - Image(systemName: "xmark.circle.fill").foregroundColor(Theme.textSecondary) - } + // Selection totals + if !vm.selectedFoodIDs.isEmpty { + HStack(spacing: 10) { + totalPill("Cal", "\(Int(vm.totalCalories()))", Theme.primary) + totalPill("Protein", "\(Int(vm.totalProtein()))g", Theme.success) + totalPill("Carbs", "\(Int(vm.totalCarbs()))g", Theme.accent) + totalPill("Fat", "\(Int(vm.totalFat()))g", Theme.warning) } } - .padding() - .background(Theme.surface) - .cornerRadius(12) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(Theme.primary.opacity(0.25), lineWidth: 1)) - // Fetch button - Button { vm.fetchRecommendations() } label: { - HStack { - if vm.isLoadingRecommendations { ProgressView().tint(.white) } - else { Image(systemName: "sparkle.magnifyingglass") } - Text(vm.isLoadingRecommendations ? "Finding meals..." : "Get Recommendations").bold() - } - .frame(maxWidth: .infinity) - .padding() - .background(vm.isLoadingRecommendations ? Color.gray : Theme.primary) - .foregroundColor(.white) - .cornerRadius(12) + if vm.selectedFoodIDs.isEmpty { + Text("Tap to select · search again to add more foods") + .font(.caption).foregroundColor(Theme.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) } - .disabled(vm.isLoadingRecommendations) - // Results - if !vm.recommendedSearchResults.isEmpty { - recommendedResults + ForEach(vm.visibleResults) { food in + foodCard(food, isInBasket: vm.pendingMealFoods.contains(where: { $0.id == food.id })) } } - .padding() - .background(Theme.surface) - .cornerRadius(16) - .overlay(RoundedRectangle(cornerRadius: 16).stroke(Theme.primary.opacity(0.25), lineWidth: 1.5)) - .shadow(color: Theme.primary.opacity(0.06), radius: 8, y: 4) } - private var dietSummary: String { - let r = vm.dietaryProfile.restrictions - if r.isEmpty { return "Personalised to your calorie & macro goals" } - return r.map { $0.rawValue }.joined(separator: " · ") - } + // MARK: - Pending Meal Basket Card - private var recommendedResults: some View { - VStack(spacing: 8) { + private var pendingMealCard: some View { + VStack(spacing: 12) { HStack { - Text("Recommended for you") - .font(.subheadline.bold()) - .foregroundColor(Theme.textPrimary) + Label("Meal in Progress", systemImage: "cart.fill") + .font(.headline).foregroundColor(Theme.textPrimary) Spacer() - Text("\(vm.recommendedSearchResults.count) items") - .font(.caption) - .foregroundColor(Theme.textSecondary) + Text("\(vm.pendingMealFoods.count) item\(vm.pendingMealFoods.count == 1 ? "" : "s")") + .font(.caption).foregroundColor(Theme.textSecondary) } - // Totals for selection - let sel = vm.recommendedSearchResults.filter { vm.recommendedSelectedIDs.contains($0.id) } - if !sel.isEmpty { - HStack(spacing: 12) { - totalPill(title: "Cal", value: "\(Int(sel.reduce(0) { $0 + $1.calories }))", color: Theme.primary) - totalPill(title: "Protein", value: "\(Int(sel.reduce(0) { $0 + $1.protein }))g", color: Theme.success) - totalPill(title: "Carbs", value: "\(Int(sel.reduce(0) { $0 + $1.carbs }))g", color: Theme.accent) - totalPill(title: "Fat", value: "\(Int(sel.reduce(0) { $0 + $1.fat }))g", color: Theme.warning) - } + // Basket totals + HStack(spacing: 10) { + totalPill("Cal", "\(Int(vm.pendingCalories))", Theme.primary) + totalPill("Protein", "\(Int(vm.pendingProtein))g", Theme.success) + totalPill("Carbs", "\(Int(vm.pendingCarbs))g", Theme.accent) + totalPill("Fat", "\(Int(vm.pendingFat))g", Theme.warning) } - ForEach(vm.recommendedSearchResults) { food in - foodCard( - food: food, - isSelected: vm.recommendedSelectedIDs.contains(food.id) - ) { - if vm.recommendedSelectedIDs.contains(food.id) { vm.recommendedSelectedIDs.remove(food.id) } - else { vm.recommendedSelectedIDs.insert(food.id) } + // Basket items + ForEach(vm.pendingMealFoods) { food in + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(food.name.capitalized).font(.subheadline.bold()).foregroundColor(Theme.textPrimary) + HStack(spacing: 8) { + nutriLabel("flame.fill", "\(Int(food.calories))kcal", Theme.primary) + nutriLabel("p.circle.fill", "\(Int(food.protein))g P", Theme.success) + nutriLabel("c.circle.fill", "\(Int(food.carbs))g C", Theme.accent) + nutriLabel("f.circle.fill", "\(Int(food.fat))g F", Theme.warning) + } + .font(.caption) + } + Spacer() + Button { vm.removeFromPendingMeal(food) } label: { + Image(systemName: "minus.circle.fill") + .foregroundColor(Theme.warning).font(.title3) + } } + .padding(12) + .background(Theme.surface) + .cornerRadius(10) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Theme.textSecondary.opacity(0.15), lineWidth: 0.5)) } - Button { - vm.saveRecommendedSelection() - showSaveRecommendedConfirmation = true - } label: { - HStack { - Image(systemName: "checkmark.circle.fill") - Text("Save Selected").bold() + // Save + Clear + HStack(spacing: 12) { + Button { + vm.savePendingMeal() + showSaveConfirmation = true + } label: { + HStack { + Image(systemName: "checkmark.circle.fill") + Text("Save Meal").bold() + } + .frame(maxWidth: .infinity).padding() + .background(Theme.success).foregroundColor(.white).cornerRadius(12) + } + + Button { vm.clearPendingMeal() } label: { + Text("Clear").bold() + .frame(maxWidth: .infinity).padding() + .foregroundColor(Theme.warning) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Theme.warning.opacity(0.5), lineWidth: 1)) } - .frame(maxWidth: .infinity) - .padding() - .background(vm.recommendedSelectedIDs.isEmpty ? Theme.textSecondary.opacity(0.3) : Theme.success) - .foregroundColor(.white) - .cornerRadius(12) } - .disabled(vm.recommendedSelectedIDs.isEmpty) } + .padding() + .background(Theme.surface) + .cornerRadius(16) + .overlay(RoundedRectangle(cornerRadius: 16).stroke(Theme.success.opacity(0.3), lineWidth: 1.5)) + .shadow(color: Theme.success.opacity(0.07), radius: 8, y: 4) } - // MARK: - Shared Sub-views + // MARK: - Food Card - private func foodCard(food: Food, isSelected: Bool, onTap: @escaping () -> Void) -> some View { - Button(action: onTap) { - HStack(alignment: .top) { + private func foodCard(_ food: Food, isInBasket: Bool) -> some View { + let isSelected = vm.selectedFoodIDs.contains(food.id) + return Button { + if isSelected { vm.selectedFoodIDs.remove(food.id) } + else { vm.selectedFoodIDs.insert(food.id) } + } label: { + HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 8) { - Text(food.name.capitalized) - .font(.subheadline.bold()) - .foregroundColor(Theme.textPrimary) - .multilineTextAlignment(.leading) - HStack(spacing: 12) { - nutrientLabel(icon: "flame.fill", value: "\(Int(food.calories))", unit: "kcal", color: Theme.primary) - nutrientLabel(icon: "p.circle.fill", value: "\(Int(food.protein))", unit: "g", color: Theme.success) - nutrientLabel(icon: "c.circle.fill", value: "\(Int(food.carbs))", unit: "g", color: Theme.accent) - nutrientLabel(icon: "f.circle.fill", value: "\(Int(food.fat))", unit: "g", color: Theme.warning) - Spacer() + HStack(spacing: 6) { + Text(food.name.capitalized) + .font(.subheadline.bold()).foregroundColor(Theme.textPrimary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + if isInBasket { + Image(systemName: "cart.badge.plus") + .font(.caption2).foregroundColor(Theme.success) + } + } + HStack(spacing: 10) { + nutriLabel("flame.fill", "\(Int(food.calories))kcal", Theme.primary) + nutriLabel("p.circle.fill", "\(Int(food.protein))g P", Theme.success) + nutriLabel("c.circle.fill", "\(Int(food.carbs))g C", Theme.accent) + nutriLabel("f.circle.fill", "\(Int(food.fat))g F", Theme.warning) } .font(.caption) } Spacer() Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .foregroundColor(isSelected ? Theme.success : Theme.textSecondary.opacity(0.4)) + .foregroundColor(isSelected ? Theme.success : Theme.textSecondary.opacity(0.35)) .font(.title3) } - .padding() - .background(isSelected ? Theme.success.opacity(0.06) : Theme.surface) + .padding(14) + .background(isSelected ? Theme.success.opacity(0.06) : isInBasket ? Theme.success.opacity(0.03) : Theme.surface) .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(isSelected ? Theme.success : Theme.textSecondary.opacity(0.2), - lineWidth: isSelected ? 1.5 : 1) - ) - .shadow(color: .black.opacity(0.04), radius: 4, y: 2) + .overlay(RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? Theme.success : Theme.textSecondary.opacity(0.18), + lineWidth: isSelected ? 1.5 : 0.5)) } .buttonStyle(.plain) .animation(.easeInOut(duration: 0.15), value: isSelected) } - private func totalPill(title: String, value: String, color: Color) -> some View { + // MARK: - Shared Helpers + + private func totalPill(_ title: String, _ value: String, _ color: Color) -> some View { VStack(spacing: 4) { - Text(title).font(.caption).foregroundColor(Theme.textSecondary) + Text(title).font(.caption2).foregroundColor(Theme.textSecondary) Text(value).font(.subheadline).bold().foregroundColor(color) } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(color.opacity(0.1)) - .cornerRadius(8) + .frame(maxWidth: .infinity).padding(.vertical, 8) + .background(color.opacity(0.1)).cornerRadius(8) } - private func nutrientLabel(icon: String, value: String, unit: String, color: Color) -> some View { - HStack(spacing: 4) { + private func nutriLabel(_ icon: String, _ text: String, _ color: Color) -> some View { + HStack(spacing: 3) { Image(systemName: icon).foregroundColor(color) - Text("\(value)\(unit)").foregroundColor(Theme.textSecondary) + Text(text).foregroundColor(Theme.textSecondary) } } } #Preview { - NavigationStack { - PlannerView(vm: MealPlannerViewModel()) - .preferredColorScheme(.light) - } + NavigationStack { PlannerView(vm: MealPlannerViewModel()) } } diff --git a/Views/Screens/SettingsView.swift b/Views/Screens/SettingsView.swift index 2a925f6..c1f94a5 100644 --- a/Views/Screens/SettingsView.swift +++ b/Views/Screens/SettingsView.swift @@ -8,6 +8,7 @@ import SwiftUI struct SettingsView: View { @ObservedObject var vm: MealPlannerViewModel @State private var showGoalCalculator = false + @State private var showDietaryPicker = false var body: some View { Form { @@ -17,6 +18,31 @@ struct SettingsView: View { Toggle("Track Fat", isOn: $vm.trackFat) Toggle("Track Carbs", isOn: $vm.trackCarbs) } + + Section(header: Text("Dietary Preferences")) { + if vm.dietaryProfile.restrictions.isEmpty { + Text("None selected") + .foregroundColor(Theme.textSecondary) + } + else { + ForEach(Array(vm.dietaryProfile.restrictions), id: \.self) { + r in HStack { + Image(systemName: r.icon).foregroundColor(Theme.primary) + Text(r.rawValue) + } + } + } + Button { + showDietaryPicker = true + } + label: { + HStack { + Image(systemName: "leaf") + Text("Edit Preferences") + } + } + .foregroundColor(Theme.primary) + } Section(header: Text("Goals")) { Button { @@ -37,8 +63,68 @@ struct SettingsView: View { .sheet(isPresented: $showGoalCalculator) { TDEECalculatorView(vm: vm) } - + .sheet(isPresented: $showDietaryPicker) { // ← add + DietaryPickerSheet(vm: vm) + } // NOTE: Avoid using objectWillChange -> state writes inside the same // view hierarchy; it can trigger continuous updates in Form. } } +private struct DietaryPickerSheet: View { + @ObservedObject var vm: MealPlannerViewModel + @Environment(\.dismiss) private var dismiss + private let columns = [GridItem(.flexible()), GridItem(.flexible())] + + var body: some View { + NavigationStack { + ScrollView { + LazyVGrid(columns: columns, spacing: 12) { + ForEach(DietaryRestriction.allCases) { restriction in + let isOn = vm.dietaryProfile.restrictions.contains(restriction) + Button { + if isOn { vm.dietaryProfile.restrictions.remove(restriction) } + else { vm.dietaryProfile.restrictions.insert(restriction) } + } label: { + HStack(spacing: 8) { + Image(systemName: restriction.icon) + .font(.caption) + .foregroundColor(isOn ? Theme.primary : Theme.textSecondary) + Text(restriction.rawValue) + .font(.subheadline) + .foregroundColor(isOn ? Theme.primary : Theme.textPrimary) + Spacer() + if isOn { + Image(systemName: "checkmark") + .font(.caption.bold()) + .foregroundColor(Theme.primary) + } + } + .padding(12) + .background(isOn ? Theme.primary.opacity(0.07) : Color(.systemBackground)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isOn ? Theme.primary.opacity(0.4) : Color(.systemGray4), lineWidth: isOn ? 1.5 : 1) + ) + } + .buttonStyle(.plain) + .animation(.easeInOut(duration: 0.15), value: isOn) + } + } + .padding() + } + .navigationTitle("Dietary Preferences") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + vm.saveDietaryProfile() + dismiss() + } + .bold() + .foregroundColor(Theme.primary) + } + } + } + } +}