fix: all model/repo/view mismatches — computed properties, missing methods, type fixes
All checks were successful
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 3s
Security Checks / dockerfile-lint (push) Successful in 3s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 02:08:40 -05:00
parent 9438421207
commit f3e5737706
9 changed files with 246 additions and 122 deletions

View File

@@ -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 { var color: Color {
switch self { switch self {
case .breakfast: return .breakfastColor case .breakfast: return .breakfastColor
@@ -66,6 +82,13 @@ struct FoodEntry: Identifiable, Codable {
var sugar: Double? { snapshotSugar } var sugar: Double? { snapshotSugar }
var fiber: Double? { snapshotFiber } var fiber: Double? { snapshotFiber }
var imageFilename: String? { foodImagePath } 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. // No CodingKeys needed convertFromSnakeCase handles all mappings.
@@ -155,6 +178,17 @@ struct FoodItem: Identifiable, Codable {
var sugar: Double? { sugarPerBase } var sugar: Double? { sugarPerBase }
var fiber: Double? { fiberPerBase } var fiber: Double? { fiberPerBase }
var servingSize: String? { baseUnit } 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 { init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: AnyCodingKey.self) let c = try decoder.container(keyedBy: AnyCodingKey.self)
@@ -206,10 +240,12 @@ struct DailyGoal: Codable {
let protein: Double let protein: Double
let carbs: Double let carbs: Double
let fat: Double let fat: Double
let sugar: Double let sugar: Double?
let fiber: Double let fiber: Double?
let isActive: Int? 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) { init(calories: Double = 2000, protein: Double = 150, carbs: Double = 250, fat: Double = 65, sugar: Double = 50, fiber: Double = 30) {
self.id = nil self.id = nil
self.calories = calories self.calories = calories
@@ -228,8 +264,8 @@ struct DailyGoal: Codable {
protein = Self.doubleFlex(c, "protein", default: 150) protein = Self.doubleFlex(c, "protein", default: 150)
carbs = Self.doubleFlex(c, "carbs", default: 250) carbs = Self.doubleFlex(c, "carbs", default: 250)
fat = Self.doubleFlex(c, "fat", default: 65) fat = Self.doubleFlex(c, "fat", default: 65)
sugar = Self.doubleFlex(c, "sugar", default: 50) sugar = Self.doubleFlexOpt(c, "sugar")
fiber = Self.doubleFlex(c, "fiber", default: 30) fiber = Self.doubleFlexOpt(c, "fiber")
if let v = try? c.decode(Int.self, forKey: AnyCodingKey("isActive")) { if let v = try? c.decode(Int.self, forKey: AnyCodingKey("isActive")) {
isActive = v isActive = v
} else if let v = try? c.decode(Double.self, forKey: AnyCodingKey("isActive")) { } 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 } if let v = try? c.decode(String.self, forKey: k), let d = Double(v) { return d }
return def 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 // MARK: - Meal Template
@@ -261,6 +305,13 @@ struct MealTemplate: Identifiable, Codable {
let totalFat: Double? let totalFat: Double?
let itemCount: Int? 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 { init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: AnyCodingKey.self) let c = try decoder.container(keyedBy: AnyCodingKey.self)
if let v = try? c.decode(String.self, forKey: AnyCodingKey("id")) { if let v = try? c.decode(String.self, forKey: AnyCodingKey("id")) {
@@ -300,16 +351,61 @@ struct MealTemplate: Identifiable, Codable {
struct CreateEntryRequest: Encodable { struct CreateEntryRequest: Encodable {
let foodId: String? let foodId: String?
let foodName: String
let mealType: String
let quantity: Double let quantity: Double
let unit: String?
let mealType: String
let entryDate: String let entryDate: String
let calories: Double let entryMethod: String?
let protein: Double let source: String?
let carbs: Double
let fat: Double // 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 sugar: Double?
let fiber: 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 { struct UpdateGoalsRequest: Encodable {
@@ -401,3 +497,28 @@ struct AnyCodingKey: CodingKey {
self.intValue = intValue 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 }
}
}

View File

@@ -4,13 +4,102 @@ import Foundation
final class FitnessRepository { final class FitnessRepository {
static let shared = FitnessRepository() static let shared = FitnessRepository()
var entries: [FoodEntry] = []
var goals: DailyGoal = DailyGoal()
var isLoading = false var isLoading = false
var error: String? var error: String?
private let api = FitnessAPI() 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 { func loadDay(date: String) async {
isLoading = true isLoading = true
error = nil error = nil
@@ -25,38 +114,8 @@ final class FitnessRepository {
isLoading = false isLoading = false
} }
func deleteEntry(id: String) async { // MARK: - Computed Helpers (legacy)
do {
try await api.deleteEntry(id: id)
entries.removeAll { $0.id == id }
} catch {
self.error = error.localizedDescription
}
}
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 totalCalories: Double { entries.reduce(0) { $0 + $1.calories * $1.quantity } }
var totalProtein: Double { entries.reduce(0) { $0 + $1.protein * $1.quantity } } var totalProtein: Double { entries.reduce(0) { $0 + $1.protein * $1.quantity } }
var totalCarbs: Double { entries.reduce(0) { $0 + $1.carbs * $1.quantity } } var totalCarbs: Double { entries.reduce(0) { $0 + $1.carbs * $1.quantity } }

View File

@@ -40,6 +40,6 @@ final class TemplatesViewModel {
} }
var groupedTemplates: [String: [MealTemplate]] { var groupedTemplates: [String: [MealTemplate]] {
Dictionary(grouping: templates, by: \.mealType) Dictionary(grouping: templates, by: { $0.mealType.rawValue })
} }
} }

View File

@@ -21,7 +21,7 @@ final class TodayViewModel {
MealType.allCases.map { meal in MealType.allCases.map { meal in
MealGroup( MealGroup(
meal: meal, meal: meal,
entries: entries.filter { $0.mealType == meal.rawValue } entries: entries.filter { $0.mealType == meal }
) )
} }
} }

View File

@@ -211,7 +211,7 @@ struct EntryDetailView: View {
.textCase(.uppercase) .textCase(.uppercase)
VStack(spacing: 0) { 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 { if let method = entry.method, !method.isEmpty {
Divider() Divider()

View File

@@ -1,82 +1,14 @@
import SwiftUI import SwiftUI
// FoodLibraryView is not currently used in the app navigation.
// Placeholder kept for future use.
struct FoodLibraryView: View { struct FoodLibraryView: View {
let dateString: String let dateString: String
@State private var vm = FoodSearchViewModel()
@State private var selectedFood: FoodItem?
var body: some View { var body: some View {
VStack(spacing: 0) { Text("Food Library")
// Search bar .font(.headline)
HStack { .foregroundStyle(Color.text3)
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) {}
}
} }
} }

View File

@@ -217,7 +217,7 @@ struct EntryRow: View {
.foregroundStyle(Color.text1) .foregroundStyle(Color.text1)
.lineLimit(1) .lineLimit(1)
Text(entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unit)") Text(entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unitLabel)")
.font(.caption) .font(.caption)
.foregroundStyle(Color.text3) .foregroundStyle(Color.text3)
.lineLimit(1) .lineLimit(1)

View File

@@ -29,7 +29,7 @@ struct TemplatesView: View {
// Ungrouped // Ungrouped
let ungrouped = viewModel.templates.filter { template in let ungrouped = viewModel.templates.filter { template in
!MealType.allCases.map(\.rawValue).contains(template.mealType) !MealType.allCases.contains(template.mealType)
} }
if !ungrouped.isEmpty { if !ungrouped.isEmpty {
templateSection(mealLabel: "Other", icon: "ellipsis.circle.fill", color: .text3, templates: ungrouped) templateSection(mealLabel: "Other", icon: "ellipsis.circle.fill", color: .text3, templates: ungrouped)

View File

@@ -39,6 +39,10 @@ extension Color {
static let lunch = Color(hex: "059669") static let lunch = Color(hex: "059669")
static let dinner = Color(hex: "3B82F6") static let dinner = Color(hex: "3B82F6")
static let snack = Color(hex: "8B5CF6") 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) { init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) 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 { static func mealIcon(for meal: String) -> String {
switch meal.lowercased() { switch meal.lowercased() {
case "breakfast": return "sunrise.fill" case "breakfast": return "sunrise.fill"
@@ -83,4 +91,8 @@ extension Color {
default: return "fork.knife" default: return "fork.knife"
} }
} }
static func mealIcon(for meal: MealType) -> String {
mealIcon(for: meal.rawValue)
}
} }