fix: all model/repo/view mismatches — computed properties, missing methods, type fixes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,22 @@ enum MealType: String, Codable, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
var capitalized: String {
|
||||
rawValue.capitalized
|
||||
}
|
||||
|
||||
/// Guess meal type based on current time of day
|
||||
static func guess() -> MealType {
|
||||
let hour = Calendar.current.component(.hour, from: Date())
|
||||
switch hour {
|
||||
case 5..<11: return .breakfast
|
||||
case 11..<15: return .lunch
|
||||
case 15..<17: return .snack
|
||||
case 17..<22: return .dinner
|
||||
default: return .snack
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .breakfast: return .breakfastColor
|
||||
@@ -66,6 +82,13 @@ struct FoodEntry: Identifiable, Codable {
|
||||
var sugar: Double? { snapshotSugar }
|
||||
var fiber: Double? { snapshotFiber }
|
||||
var imageFilename: String? { foodImagePath }
|
||||
var imageUrl: String? { foodImagePath }
|
||||
var method: String? { entryMethod }
|
||||
var loggedAt: String? { nil }
|
||||
/// Convenience: raw string for the meal type (used by Color.mealColor(for:))
|
||||
var mealTypeString: String { mealType.rawValue }
|
||||
/// Fallback unit string for display
|
||||
var unitLabel: String { unit ?? "serving" }
|
||||
|
||||
// No CodingKeys needed — convertFromSnakeCase handles all mappings.
|
||||
|
||||
@@ -155,6 +178,17 @@ struct FoodItem: Identifiable, Codable {
|
||||
var sugar: Double? { sugarPerBase }
|
||||
var fiber: Double? { fiberPerBase }
|
||||
var servingSize: String? { baseUnit }
|
||||
var imageUrl: String? { imageFilename }
|
||||
var displayUnit: String { baseUnit ?? "serving" }
|
||||
var displayInfo: String {
|
||||
let cal = Int(caloriesPerBase)
|
||||
return "\(cal) cal per \(displayUnit)"
|
||||
}
|
||||
|
||||
func scaledCalories(quantity: Double) -> Double { caloriesPerBase * quantity }
|
||||
func scaledProtein(quantity: Double) -> Double { proteinPerBase * quantity }
|
||||
func scaledCarbs(quantity: Double) -> Double { carbsPerBase * quantity }
|
||||
func scaledFat(quantity: Double) -> Double { fatPerBase * quantity }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||
@@ -206,10 +240,12 @@ struct DailyGoal: Codable {
|
||||
let protein: Double
|
||||
let carbs: Double
|
||||
let fat: Double
|
||||
let sugar: Double
|
||||
let fiber: Double
|
||||
let sugar: Double?
|
||||
let fiber: Double?
|
||||
let isActive: Int?
|
||||
|
||||
static let defaultGoal = DailyGoal()
|
||||
|
||||
init(calories: Double = 2000, protein: Double = 150, carbs: Double = 250, fat: Double = 65, sugar: Double = 50, fiber: Double = 30) {
|
||||
self.id = nil
|
||||
self.calories = calories
|
||||
@@ -228,8 +264,8 @@ struct DailyGoal: Codable {
|
||||
protein = Self.doubleFlex(c, "protein", default: 150)
|
||||
carbs = Self.doubleFlex(c, "carbs", default: 250)
|
||||
fat = Self.doubleFlex(c, "fat", default: 65)
|
||||
sugar = Self.doubleFlex(c, "sugar", default: 50)
|
||||
fiber = Self.doubleFlex(c, "fiber", default: 30)
|
||||
sugar = Self.doubleFlexOpt(c, "sugar")
|
||||
fiber = Self.doubleFlexOpt(c, "fiber")
|
||||
if let v = try? c.decode(Int.self, forKey: AnyCodingKey("isActive")) {
|
||||
isActive = v
|
||||
} else if let v = try? c.decode(Double.self, forKey: AnyCodingKey("isActive")) {
|
||||
@@ -246,6 +282,14 @@ struct DailyGoal: Codable {
|
||||
if let v = try? c.decode(String.self, forKey: k), let d = Double(v) { return d }
|
||||
return def
|
||||
}
|
||||
|
||||
private static func doubleFlexOpt(_ c: KeyedDecodingContainer<AnyCodingKey>, _ key: String) -> Double? {
|
||||
let k = AnyCodingKey(key)
|
||||
if let v = try? c.decode(Double.self, forKey: k) { return v }
|
||||
if let v = try? c.decode(Int.self, forKey: k) { return Double(v) }
|
||||
if let v = try? c.decode(String.self, forKey: k), let d = Double(v) { return d }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Meal Template
|
||||
@@ -261,6 +305,13 @@ struct MealTemplate: Identifiable, Codable {
|
||||
let totalFat: Double?
|
||||
let itemCount: Int?
|
||||
|
||||
// Convenience accessors used by views
|
||||
var calories: Double { totalCalories ?? 0 }
|
||||
var protein: Double? { totalProtein }
|
||||
var carbs: Double? { totalCarbs }
|
||||
var fat: Double? { totalFat }
|
||||
var itemsCount: Int? { itemCount }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||
if let v = try? c.decode(String.self, forKey: AnyCodingKey("id")) {
|
||||
@@ -300,16 +351,61 @@ struct MealTemplate: Identifiable, Codable {
|
||||
|
||||
struct CreateEntryRequest: Encodable {
|
||||
let foodId: String?
|
||||
let foodName: String
|
||||
let mealType: String
|
||||
let quantity: Double
|
||||
let unit: String?
|
||||
let mealType: String
|
||||
let entryDate: String
|
||||
let calories: Double
|
||||
let protein: Double
|
||||
let carbs: Double
|
||||
let fat: Double
|
||||
let entryMethod: String?
|
||||
let source: String?
|
||||
|
||||
// Additional fields for manual entries without a foodId
|
||||
let foodName: String?
|
||||
let calories: Double?
|
||||
let protein: Double?
|
||||
let carbs: Double?
|
||||
let fat: Double?
|
||||
let sugar: Double?
|
||||
let fiber: Double?
|
||||
|
||||
/// Convenience init for adding from food library (foodId-based)
|
||||
init(foodId: String, quantity: Double, unit: String, mealType: String, entryDate: String, entryMethod: String? = "manual", source: String? = "ios_app") {
|
||||
self.foodId = foodId
|
||||
self.quantity = quantity
|
||||
self.unit = unit
|
||||
self.mealType = mealType
|
||||
self.entryDate = entryDate
|
||||
self.entryMethod = entryMethod
|
||||
self.source = source
|
||||
self.foodName = nil
|
||||
self.calories = nil
|
||||
self.protein = nil
|
||||
self.carbs = nil
|
||||
self.fat = nil
|
||||
self.sugar = nil
|
||||
self.fiber = nil
|
||||
}
|
||||
|
||||
/// Convenience init for manual entries (with inline macros)
|
||||
init(foodId: String? = nil, foodName: String, mealType: String, quantity: Double, entryDate: String, calories: Double, protein: Double, carbs: Double, fat: Double, sugar: Double? = nil, fiber: Double? = nil) {
|
||||
self.foodId = foodId
|
||||
self.foodName = foodName
|
||||
self.mealType = mealType
|
||||
self.quantity = quantity
|
||||
self.entryDate = entryDate
|
||||
self.calories = calories
|
||||
self.protein = protein
|
||||
self.carbs = carbs
|
||||
self.fat = fat
|
||||
self.sugar = sugar
|
||||
self.fiber = fiber
|
||||
self.unit = nil
|
||||
self.entryMethod = "manual"
|
||||
self.source = "ios_app"
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdateEntryRequest: Encodable {
|
||||
let quantity: Double
|
||||
}
|
||||
|
||||
struct UpdateGoalsRequest: Encodable {
|
||||
@@ -401,3 +497,28 @@ struct AnyCodingKey: CodingKey {
|
||||
self.intValue = intValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Meal Group (used by TodayView)
|
||||
|
||||
struct MealGroup: Identifiable {
|
||||
let meal: MealType
|
||||
let entries: [FoodEntry]
|
||||
|
||||
var id: String { meal.rawValue }
|
||||
|
||||
var totalCalories: Double {
|
||||
entries.reduce(0) { $0 + $1.calories }
|
||||
}
|
||||
|
||||
var totalProtein: Double {
|
||||
entries.reduce(0) { $0 + $1.protein }
|
||||
}
|
||||
|
||||
var totalCarbs: Double {
|
||||
entries.reduce(0) { $0 + $1.carbs }
|
||||
}
|
||||
|
||||
var totalFat: Double {
|
||||
entries.reduce(0) { $0 + $1.fat }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,102 @@ import Foundation
|
||||
final class FitnessRepository {
|
||||
static let shared = FitnessRepository()
|
||||
|
||||
var entries: [FoodEntry] = []
|
||||
var goals: DailyGoal = DailyGoal()
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
|
||||
private let api = FitnessAPI()
|
||||
|
||||
// Caches
|
||||
private var entriesCache: [String: [FoodEntry]] = [:]
|
||||
private var goalsCache: [String: DailyGoal] = [:]
|
||||
private var recentFoodsCache: [FoodItem]?
|
||||
private var templatesCache: [MealTemplate]?
|
||||
|
||||
// MARK: - Entries
|
||||
|
||||
func entries(for date: String, forceRefresh: Bool = false) async throws -> [FoodEntry] {
|
||||
if !forceRefresh, let cached = entriesCache[date] {
|
||||
return cached
|
||||
}
|
||||
let result = try await api.getEntries(date: date)
|
||||
entriesCache[date] = result
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Goals
|
||||
|
||||
func goals(for date: String) async throws -> DailyGoal {
|
||||
if let cached = goalsCache[date] {
|
||||
return cached
|
||||
}
|
||||
let result = try await api.getGoals(date: date)
|
||||
goalsCache[date] = result
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Create / Update / Delete Entries
|
||||
|
||||
func createEntry(_ req: CreateEntryRequest) async throws -> FoodEntry {
|
||||
let entry = try await api.createEntry(req)
|
||||
// Invalidate cache for the entry date
|
||||
entriesCache.removeValue(forKey: entry.entryDate)
|
||||
return entry
|
||||
}
|
||||
|
||||
/// Alias kept for backward compatibility
|
||||
func addEntry(_ req: CreateEntryRequest) async throws -> FoodEntry {
|
||||
try await createEntry(req)
|
||||
}
|
||||
|
||||
func updateEntry(id: String, request: UpdateEntryRequest, date: String) async throws -> FoodEntry {
|
||||
let updated = try await api.updateEntry(id: id, quantity: request.quantity)
|
||||
entriesCache.removeValue(forKey: date)
|
||||
return updated
|
||||
}
|
||||
|
||||
func deleteEntry(id: String, date: String) async throws {
|
||||
try await api.deleteEntry(id: id)
|
||||
entriesCache[date]?.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
// MARK: - Food Search
|
||||
|
||||
func searchFoods(query: String) async throws -> [FoodItem] {
|
||||
try await api.searchFoods(query: query)
|
||||
}
|
||||
|
||||
func recentFoods(forceRefresh: Bool = false) async throws -> [FoodItem] {
|
||||
if !forceRefresh, let cached = recentFoodsCache {
|
||||
return cached
|
||||
}
|
||||
let result = try await api.getRecentFoods()
|
||||
recentFoodsCache = result
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Templates
|
||||
|
||||
func templates(forceRefresh: Bool = false) async throws -> [MealTemplate] {
|
||||
if !forceRefresh, let cached = templatesCache {
|
||||
return cached
|
||||
}
|
||||
let result = try await api.getTemplates()
|
||||
templatesCache = result
|
||||
return result
|
||||
}
|
||||
|
||||
func logTemplate(id: String, date: String) async throws {
|
||||
try await api.logTemplate(id: id, date: date)
|
||||
// Invalidate entries cache for that date so it reloads
|
||||
entriesCache.removeValue(forKey: date)
|
||||
}
|
||||
|
||||
// MARK: - Legacy loadDay (used by GoalsViewModel)
|
||||
|
||||
/// Kept for GoalsViewModel compatibility — loads entries + goals into the old-style properties.
|
||||
var entries: [FoodEntry] = []
|
||||
var goals: DailyGoal = .defaultGoal
|
||||
|
||||
func loadDay(date: String) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
@@ -25,38 +114,8 @@ final class FitnessRepository {
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func deleteEntry(id: String) async {
|
||||
do {
|
||||
try await api.deleteEntry(id: id)
|
||||
entries.removeAll { $0.id == id }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
// MARK: - Computed Helpers (legacy)
|
||||
|
||||
func addEntry(_ req: CreateEntryRequest) async {
|
||||
do {
|
||||
let entry = try await api.createEntry(req)
|
||||
entries.append(entry)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func updateEntry(id: String, quantity: Double) async -> FoodEntry? {
|
||||
do {
|
||||
let updated = try await api.updateEntry(id: id, quantity: quantity)
|
||||
if let idx = entries.firstIndex(where: { $0.id == id }) {
|
||||
entries[idx] = updated
|
||||
}
|
||||
return updated
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Computed helpers
|
||||
var totalCalories: Double { entries.reduce(0) { $0 + $1.calories * $1.quantity } }
|
||||
var totalProtein: Double { entries.reduce(0) { $0 + $1.protein * $1.quantity } }
|
||||
var totalCarbs: Double { entries.reduce(0) { $0 + $1.carbs * $1.quantity } }
|
||||
|
||||
@@ -40,6 +40,6 @@ final class TemplatesViewModel {
|
||||
}
|
||||
|
||||
var groupedTemplates: [String: [MealTemplate]] {
|
||||
Dictionary(grouping: templates, by: \.mealType)
|
||||
Dictionary(grouping: templates, by: { $0.mealType.rawValue })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ final class TodayViewModel {
|
||||
MealType.allCases.map { meal in
|
||||
MealGroup(
|
||||
meal: meal,
|
||||
entries: entries.filter { $0.mealType == meal.rawValue }
|
||||
entries: entries.filter { $0.mealType == meal }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ struct EntryDetailView: View {
|
||||
.textCase(.uppercase)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
detailRow("Serving", value: entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unit)")
|
||||
detailRow("Serving", value: entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unitLabel)")
|
||||
|
||||
if let method = entry.method, !method.isEmpty {
|
||||
Divider()
|
||||
|
||||
@@ -1,82 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
// FoodLibraryView is not currently used in the app navigation.
|
||||
// Placeholder kept for future use.
|
||||
|
||||
struct FoodLibraryView: View {
|
||||
let dateString: String
|
||||
@State private var vm = FoodSearchViewModel()
|
||||
@State private var selectedFood: FoodItem?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Search bar
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
TextField("Search foods...", text: $vm.query)
|
||||
.textFieldStyle(.plain)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: vm.query) { _, _ in
|
||||
vm.search()
|
||||
}
|
||||
if !vm.query.isEmpty {
|
||||
Button { vm.query = ""; vm.results = [] } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
if vm.isLoadingInitial {
|
||||
LoadingView()
|
||||
} else {
|
||||
let foods = vm.query.count >= 2 ? vm.results : vm.allFoods
|
||||
if foods.isEmpty {
|
||||
EmptyStateView(icon: "fork.knife", title: "No foods", subtitle: "Your food library is empty")
|
||||
} else {
|
||||
List(foods) { food in
|
||||
Button {
|
||||
selectedFood = food
|
||||
} label: {
|
||||
HStack {
|
||||
if let img = food.imageFilename {
|
||||
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in
|
||||
image.resizable().aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Color.surfaceSecondary
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(food.name)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
HStack(spacing: 8) {
|
||||
Text("\(Int(food.calories)) cal")
|
||||
Text("P:\(Int(food.protein))g")
|
||||
Text("C:\(Int(food.carbs))g")
|
||||
Text("F:\(Int(food.fat))g")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await vm.loadInitial()
|
||||
}
|
||||
.sheet(item: $selectedFood) { food in
|
||||
AddFoodSheet(food: food, mealType: .snack, dateString: dateString) {}
|
||||
}
|
||||
Text("Food Library")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.text3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ struct EntryRow: View {
|
||||
.foregroundStyle(Color.text1)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unit)")
|
||||
Text(entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unitLabel)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.text3)
|
||||
.lineLimit(1)
|
||||
|
||||
@@ -29,7 +29,7 @@ struct TemplatesView: View {
|
||||
|
||||
// Ungrouped
|
||||
let ungrouped = viewModel.templates.filter { template in
|
||||
!MealType.allCases.map(\.rawValue).contains(template.mealType)
|
||||
!MealType.allCases.contains(template.mealType)
|
||||
}
|
||||
if !ungrouped.isEmpty {
|
||||
templateSection(mealLabel: "Other", icon: "ellipsis.circle.fill", color: .text3, templates: ungrouped)
|
||||
|
||||
@@ -39,6 +39,10 @@ extension Color {
|
||||
static let lunch = Color(hex: "059669")
|
||||
static let dinner = Color(hex: "3B82F6")
|
||||
static let snack = Color(hex: "8B5CF6")
|
||||
static let breakfastColor = Color(hex: "F59E0B")
|
||||
static let lunchColor = Color(hex: "059669")
|
||||
static let dinnerColor = Color(hex: "3B82F6")
|
||||
static let snackColor = Color(hex: "8B5CF6")
|
||||
|
||||
init(hex: String) {
|
||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
@@ -74,6 +78,10 @@ extension Color {
|
||||
}
|
||||
}
|
||||
|
||||
static func mealColor(for meal: MealType) -> Color {
|
||||
mealColor(for: meal.rawValue)
|
||||
}
|
||||
|
||||
static func mealIcon(for meal: String) -> String {
|
||||
switch meal.lowercased() {
|
||||
case "breakfast": return "sunrise.fill"
|
||||
@@ -83,4 +91,8 @@ extension Color {
|
||||
default: return "fork.knife"
|
||||
}
|
||||
}
|
||||
|
||||
static func mealIcon(for meal: MealType) -> String {
|
||||
mealIcon(for: meal.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user