fix: iOS fitness models — UUID strings, snapshot_ fields, convertFromSnakeCase compatibility
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,6 @@ struct MainTabView: View {
|
||||
}
|
||||
.tag(1)
|
||||
}
|
||||
.tint(.accent)
|
||||
.tint(.accentWarm)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,10 +33,10 @@ struct AssistantChatView: View {
|
||||
} label: {
|
||||
Text(tab.rawValue)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(selectedTab == tab ? Color.accent : Color.textSecondary)
|
||||
.foregroundStyle(selectedTab == tab ? Color.accentWarm : Color.textSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(selectedTab == tab ? Color.accent.opacity(0.1) : Color.clear)
|
||||
.background(selectedTab == tab ? Color.accentWarm.opacity(0.1) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ struct AssistantChatView: View {
|
||||
if vm.isLoading {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.tint(Color.accent)
|
||||
.tint(Color.accentWarm)
|
||||
Text("Thinking...")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
@@ -122,7 +122,7 @@ struct AssistantChatView: View {
|
||||
), matching: .images) {
|
||||
Image(systemName: vm.photoData != nil ? "photo.fill" : "photo")
|
||||
.font(.title3)
|
||||
.foregroundStyle(vm.photoData != nil ? Color.accent : Color.textSecondary)
|
||||
.foregroundStyle(vm.photoData != nil ? Color.accentWarm : Color.textSecondary)
|
||||
}
|
||||
|
||||
TextField("Ask anything...", text: $vm.inputText)
|
||||
@@ -141,7 +141,7 @@ struct AssistantChatView: View {
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(vm.inputText.isEmpty && vm.photoData == nil ? Color.textTertiary : Color.accent)
|
||||
.foregroundStyle(vm.inputText.isEmpty && vm.photoData == nil ? Color.textTertiary : Color.accentWarm)
|
||||
}
|
||||
.disabled(vm.inputText.isEmpty && vm.photoData == nil)
|
||||
}
|
||||
@@ -212,10 +212,10 @@ struct AssistantChatView: View {
|
||||
Spacer()
|
||||
Text(draft.mealType.capitalized)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(Color.accent)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.accent.opacity(0.1))
|
||||
.background(Color.accentWarm.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
@@ -276,10 +276,10 @@ struct AssistantChatView: View {
|
||||
.font(.caption2)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(Color.accent)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.accent.opacity(0.08))
|
||||
.background(Color.accentWarm.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ struct LoginView: View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "square.grid.2x2.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(Color.accent)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
Text("Platform")
|
||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
@@ -67,7 +67,7 @@ struct LoginView: View {
|
||||
.frame(height: 48)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.accent)
|
||||
.tint(Color.accentWarm)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 32)
|
||||
.disabled(username.isEmpty || password.isEmpty || isLoading)
|
||||
|
||||
@@ -11,12 +11,12 @@ struct FitnessAPI {
|
||||
try await api.post("/api/fitness/entries", body: req)
|
||||
}
|
||||
|
||||
func updateEntry(id: Int, quantity: Double) async throws -> FoodEntry {
|
||||
func updateEntry(id: String, quantity: Double) async throws -> FoodEntry {
|
||||
struct Body: Encodable { let quantity: Double }
|
||||
return try await api.patch("/api/fitness/entries/\(id)", body: Body(quantity: quantity))
|
||||
}
|
||||
|
||||
func deleteEntry(id: Int) async throws {
|
||||
func deleteEntry(id: String) async throws {
|
||||
try await api.delete("/api/fitness/entries/\(id)")
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ struct FitnessAPI {
|
||||
try await api.get("/api/fitness/foods/recent?limit=\(limit)")
|
||||
}
|
||||
|
||||
func getFood(id: Int) async throws -> FoodItem {
|
||||
func getFood(id: String) async throws -> FoodItem {
|
||||
try await api.get("/api/fitness/foods/\(id)")
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ struct FitnessAPI {
|
||||
try await api.get("/api/fitness/templates")
|
||||
}
|
||||
|
||||
func logTemplate(id: Int, date: String) async throws {
|
||||
func logTemplate(id: String, date: String) async throws {
|
||||
struct Empty: Decodable {}
|
||||
let _: Empty = try await api.post("/api/fitness/templates/\(id)/log?date=\(date)")
|
||||
}
|
||||
|
||||
@@ -32,175 +32,227 @@ enum MealType: String, Codable, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
// MARK: - Food Entry
|
||||
// API fields (snake_case) are auto-converted to camelCase by decoder.
|
||||
// The API returns snapshot_food_name, snapshot_calories, etc. — no top-level food_name/calories.
|
||||
|
||||
struct FoodEntry: Identifiable, Codable {
|
||||
let id: Int
|
||||
let userId: Int?
|
||||
let foodId: Int?
|
||||
let id: String
|
||||
let userId: String?
|
||||
let foodId: String?
|
||||
let mealType: MealType
|
||||
let quantity: Double
|
||||
let entryDate: String
|
||||
let foodName: String
|
||||
let calories: Double
|
||||
let protein: Double
|
||||
let carbs: Double
|
||||
let fat: Double
|
||||
let sugar: Double?
|
||||
let fiber: Double?
|
||||
let servingSize: String?
|
||||
let imageFilename: String?
|
||||
let entryType: String?
|
||||
let unit: String?
|
||||
let servingDescription: String?
|
||||
let snapshotFoodName: String?
|
||||
let snapshotServingLabel: String?
|
||||
let snapshotCalories: Double
|
||||
let snapshotProtein: Double
|
||||
let snapshotCarbs: Double
|
||||
let snapshotFat: Double
|
||||
let snapshotSugar: Double?
|
||||
let snapshotFiber: Double?
|
||||
let foodImagePath: String?
|
||||
let note: String?
|
||||
let entryMethod: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case userId = "user_id"
|
||||
case foodId = "food_id"
|
||||
case mealType = "meal_type"
|
||||
case quantity
|
||||
case entryDate = "entry_date"
|
||||
case foodName = "food_name"
|
||||
case snapshotFoodName = "snapshot_food_name"
|
||||
case calories, protein, carbs, fat, sugar, fiber
|
||||
case servingSize = "serving_size"
|
||||
case imageFilename = "image_filename"
|
||||
}
|
||||
// Computed convenience accessors used by views
|
||||
var foodName: String { snapshotFoodName ?? "Unknown" }
|
||||
var calories: Double { snapshotCalories }
|
||||
var protein: Double { snapshotProtein }
|
||||
var carbs: Double { snapshotCarbs }
|
||||
var fat: Double { snapshotFat }
|
||||
var sugar: Double? { snapshotSugar }
|
||||
var fiber: Double? { snapshotFiber }
|
||||
var imageFilename: String? { foodImagePath }
|
||||
|
||||
// No CodingKeys needed — convertFromSnakeCase handles all mappings.
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try Self.decodeIntFlex(c, .id)
|
||||
userId = try? Self.decodeIntFlex(c, .userId)
|
||||
foodId = try? Self.decodeIntFlex(c, .foodId)
|
||||
mealType = try c.decode(MealType.self, forKey: .mealType)
|
||||
quantity = try Self.decodeDoubleFlex(c, .quantity)
|
||||
entryDate = try c.decode(String.self, forKey: .entryDate)
|
||||
// Handle food_name or snapshot_food_name
|
||||
if let name = try? c.decode(String.self, forKey: .foodName) {
|
||||
foodName = name
|
||||
} else if let name = try? c.decode(String.self, forKey: .snapshotFoodName) {
|
||||
foodName = name
|
||||
} else {
|
||||
foodName = "Unknown"
|
||||
}
|
||||
calories = try Self.decodeDoubleFlex(c, .calories)
|
||||
protein = try Self.decodeDoubleFlex(c, .protein)
|
||||
carbs = try Self.decodeDoubleFlex(c, .carbs)
|
||||
fat = try Self.decodeDoubleFlex(c, .fat)
|
||||
sugar = try? Self.decodeDoubleFlex(c, .sugar)
|
||||
fiber = try? Self.decodeDoubleFlex(c, .fiber)
|
||||
servingSize = try? c.decode(String.self, forKey: .servingSize)
|
||||
imageFilename = try? c.decode(String.self, forKey: .imageFilename)
|
||||
let c = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||
id = Self.decodeStringFlex(c, "id") ?? ""
|
||||
userId = Self.decodeStringFlex(c, "userId")
|
||||
foodId = Self.decodeStringFlex(c, "foodId")
|
||||
mealType = (try? c.decode(MealType.self, forKey: AnyCodingKey("mealType"))) ?? .snack
|
||||
quantity = Self.decodeDoubleFlex(c, "quantity") ?? 1.0
|
||||
entryDate = (try? c.decode(String.self, forKey: AnyCodingKey("entryDate"))) ?? ""
|
||||
entryType = try? c.decode(String.self, forKey: AnyCodingKey("entryType"))
|
||||
unit = try? c.decode(String.self, forKey: AnyCodingKey("unit"))
|
||||
servingDescription = try? c.decode(String.self, forKey: AnyCodingKey("servingDescription"))
|
||||
snapshotFoodName = try? c.decode(String.self, forKey: AnyCodingKey("snapshotFoodName"))
|
||||
snapshotServingLabel = try? c.decode(String.self, forKey: AnyCodingKey("snapshotServingLabel"))
|
||||
snapshotCalories = Self.decodeDoubleFlex(c, "snapshotCalories") ?? 0
|
||||
snapshotProtein = Self.decodeDoubleFlex(c, "snapshotProtein") ?? 0
|
||||
snapshotCarbs = Self.decodeDoubleFlex(c, "snapshotCarbs") ?? 0
|
||||
snapshotFat = Self.decodeDoubleFlex(c, "snapshotFat") ?? 0
|
||||
snapshotSugar = Self.decodeDoubleFlex(c, "snapshotSugar")
|
||||
snapshotFiber = Self.decodeDoubleFlex(c, "snapshotFiber")
|
||||
foodImagePath = try? c.decode(String.self, forKey: AnyCodingKey("foodImagePath"))
|
||||
note = try? c.decode(String.self, forKey: AnyCodingKey("note"))
|
||||
entryMethod = try? c.decode(String.self, forKey: AnyCodingKey("entryMethod"))
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(id, forKey: .id)
|
||||
try c.encodeIfPresent(userId, forKey: .userId)
|
||||
try c.encodeIfPresent(foodId, forKey: .foodId)
|
||||
try c.encode(mealType, forKey: .mealType)
|
||||
try c.encode(quantity, forKey: .quantity)
|
||||
try c.encode(entryDate, forKey: .entryDate)
|
||||
try c.encode(foodName, forKey: .foodName)
|
||||
try c.encode(calories, forKey: .calories)
|
||||
try c.encode(protein, forKey: .protein)
|
||||
try c.encode(carbs, forKey: .carbs)
|
||||
try c.encode(fat, forKey: .fat)
|
||||
try c.encodeIfPresent(sugar, forKey: .sugar)
|
||||
try c.encodeIfPresent(fiber, forKey: .fiber)
|
||||
try c.encodeIfPresent(servingSize, forKey: .servingSize)
|
||||
try c.encodeIfPresent(imageFilename, forKey: .imageFilename)
|
||||
var c = encoder.container(keyedBy: AnyCodingKey.self)
|
||||
try c.encode(id, forKey: AnyCodingKey("id"))
|
||||
try c.encodeIfPresent(userId, forKey: AnyCodingKey("userId"))
|
||||
try c.encodeIfPresent(foodId, forKey: AnyCodingKey("foodId"))
|
||||
try c.encode(mealType, forKey: AnyCodingKey("mealType"))
|
||||
try c.encode(quantity, forKey: AnyCodingKey("quantity"))
|
||||
try c.encode(entryDate, forKey: AnyCodingKey("entryDate"))
|
||||
try c.encodeIfPresent(snapshotFoodName, forKey: AnyCodingKey("snapshotFoodName"))
|
||||
try c.encode(snapshotCalories, forKey: AnyCodingKey("snapshotCalories"))
|
||||
try c.encode(snapshotProtein, forKey: AnyCodingKey("snapshotProtein"))
|
||||
try c.encode(snapshotCarbs, forKey: AnyCodingKey("snapshotCarbs"))
|
||||
try c.encode(snapshotFat, forKey: AnyCodingKey("snapshotFat"))
|
||||
try c.encodeIfPresent(snapshotSugar, forKey: AnyCodingKey("snapshotSugar"))
|
||||
try c.encodeIfPresent(snapshotFiber, forKey: AnyCodingKey("snapshotFiber"))
|
||||
try c.encodeIfPresent(foodImagePath, forKey: AnyCodingKey("foodImagePath"))
|
||||
}
|
||||
|
||||
// Flexible Int decoding
|
||||
private static func decodeIntFlex(_ c: KeyedDecodingContainer<CodingKeys>, _ key: CodingKeys) throws -> Int {
|
||||
if let v = try? c.decode(Int.self, forKey: key) { return v }
|
||||
if let v = try? c.decode(Double.self, forKey: key) { return Int(v) }
|
||||
if let v = try? c.decode(String.self, forKey: key), let i = Int(v) { return i }
|
||||
throw DecodingError.typeMismatch(Int.self, .init(codingPath: [key], debugDescription: "Expected numeric"))
|
||||
// Flexible decoding helpers
|
||||
private static func decodeDoubleFlex(_ 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
|
||||
}
|
||||
|
||||
// Flexible Double decoding
|
||||
private static func decodeDoubleFlex(_ c: KeyedDecodingContainer<CodingKeys>, _ key: CodingKeys) throws -> Double {
|
||||
if let v = try? c.decode(Double.self, forKey: key) { return v }
|
||||
if let v = try? c.decode(Int.self, forKey: key) { return Double(v) }
|
||||
if let v = try? c.decode(String.self, forKey: key), let d = Double(v) { return d }
|
||||
throw DecodingError.typeMismatch(Double.self, .init(codingPath: [key], debugDescription: "Expected numeric"))
|
||||
private static func decodeStringFlex(_ c: KeyedDecodingContainer<AnyCodingKey>, _ key: String) -> String? {
|
||||
let k = AnyCodingKey(key)
|
||||
if let v = try? c.decode(String.self, forKey: k) { return v }
|
||||
if let v = try? c.decode(Int.self, forKey: k) { return String(v) }
|
||||
if let v = try? c.decode(Double.self, forKey: k) { return String(Int(v)) }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Food Item
|
||||
// API: id (UUID string), name, brand, base_unit, calories_per_base, protein_per_base, etc.
|
||||
|
||||
struct FoodItem: Identifiable, Codable {
|
||||
let id: Int
|
||||
let id: String
|
||||
let name: String
|
||||
let calories: Double
|
||||
let protein: Double
|
||||
let carbs: Double
|
||||
let fat: Double
|
||||
let sugar: Double?
|
||||
let fiber: Double?
|
||||
let servingSize: String?
|
||||
let brand: String?
|
||||
let baseUnit: String?
|
||||
let caloriesPerBase: Double
|
||||
let proteinPerBase: Double
|
||||
let carbsPerBase: Double
|
||||
let fatPerBase: Double
|
||||
let sugarPerBase: Double?
|
||||
let fiberPerBase: Double?
|
||||
let status: String?
|
||||
let imageFilename: String?
|
||||
let favorite: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, calories, protein, carbs, fat, sugar, fiber
|
||||
case servingSize = "serving_size"
|
||||
case imageFilename = "image_filename"
|
||||
}
|
||||
// Computed convenience accessors (used by views that reference .calories, .protein, etc.)
|
||||
var calories: Double { caloriesPerBase }
|
||||
var protein: Double { proteinPerBase }
|
||||
var carbs: Double { carbsPerBase }
|
||||
var fat: Double { fatPerBase }
|
||||
var sugar: Double? { sugarPerBase }
|
||||
var fiber: Double? { fiberPerBase }
|
||||
var servingSize: String? { baseUnit }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
if let v = try? c.decode(Int.self, forKey: .id) { id = v }
|
||||
else if let v = try? c.decode(Double.self, forKey: .id) { id = Int(v) }
|
||||
else { id = 0 }
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
calories = (try? c.decode(Double.self, forKey: .calories)) ?? Double((try? c.decode(Int.self, forKey: .calories)) ?? 0)
|
||||
protein = (try? c.decode(Double.self, forKey: .protein)) ?? Double((try? c.decode(Int.self, forKey: .protein)) ?? 0)
|
||||
carbs = (try? c.decode(Double.self, forKey: .carbs)) ?? Double((try? c.decode(Int.self, forKey: .carbs)) ?? 0)
|
||||
fat = (try? c.decode(Double.self, forKey: .fat)) ?? Double((try? c.decode(Int.self, forKey: .fat)) ?? 0)
|
||||
sugar = (try? c.decode(Double.self, forKey: .sugar)) ?? (try? c.decode(Int.self, forKey: .sugar)).map { Double($0) }
|
||||
fiber = (try? c.decode(Double.self, forKey: .fiber)) ?? (try? c.decode(Int.self, forKey: .fiber)).map { Double($0) }
|
||||
servingSize = try? c.decode(String.self, forKey: .servingSize)
|
||||
imageFilename = try? c.decode(String.self, forKey: .imageFilename)
|
||||
let c = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||
// id can be String or Int
|
||||
if let v = try? c.decode(String.self, forKey: AnyCodingKey("id")) {
|
||||
id = v
|
||||
} else if let v = try? c.decode(Int.self, forKey: AnyCodingKey("id")) {
|
||||
id = String(v)
|
||||
} else {
|
||||
id = ""
|
||||
}
|
||||
name = (try? c.decode(String.self, forKey: AnyCodingKey("name"))) ?? ""
|
||||
brand = try? c.decode(String.self, forKey: AnyCodingKey("brand"))
|
||||
baseUnit = try? c.decode(String.self, forKey: AnyCodingKey("baseUnit"))
|
||||
caloriesPerBase = Self.doubleFlex(c, "caloriesPerBase")
|
||||
proteinPerBase = Self.doubleFlex(c, "proteinPerBase")
|
||||
carbsPerBase = Self.doubleFlex(c, "carbsPerBase")
|
||||
fatPerBase = Self.doubleFlex(c, "fatPerBase")
|
||||
sugarPerBase = Self.doubleFlexOpt(c, "sugarPerBase")
|
||||
fiberPerBase = Self.doubleFlexOpt(c, "fiberPerBase")
|
||||
status = try? c.decode(String.self, forKey: AnyCodingKey("status"))
|
||||
imageFilename = try? c.decode(String.self, forKey: AnyCodingKey("imageFilename"))
|
||||
favorite = try? c.decode(Bool.self, forKey: AnyCodingKey("favorite"))
|
||||
}
|
||||
|
||||
private static func doubleFlex(_ 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 0
|
||||
}
|
||||
|
||||
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: - Daily Goal
|
||||
// API: id (UUID), calories, protein, carbs, fat, sugar, fiber, is_active
|
||||
|
||||
struct DailyGoal: Codable {
|
||||
let id: String?
|
||||
let calories: Double
|
||||
let protein: Double
|
||||
let carbs: Double
|
||||
let fat: Double
|
||||
let sugar: Double
|
||||
let fiber: Double
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case calories, protein, carbs, fat, sugar, fiber
|
||||
}
|
||||
let isActive: Int?
|
||||
|
||||
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
|
||||
self.protein = protein
|
||||
self.carbs = carbs
|
||||
self.fat = fat
|
||||
self.sugar = sugar
|
||||
self.fiber = fiber
|
||||
self.isActive = nil
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
calories = (try? c.decode(Double.self, forKey: .calories)) ?? Double((try? c.decode(Int.self, forKey: .calories)) ?? 2000)
|
||||
protein = (try? c.decode(Double.self, forKey: .protein)) ?? Double((try? c.decode(Int.self, forKey: .protein)) ?? 150)
|
||||
carbs = (try? c.decode(Double.self, forKey: .carbs)) ?? Double((try? c.decode(Int.self, forKey: .carbs)) ?? 250)
|
||||
fat = (try? c.decode(Double.self, forKey: .fat)) ?? Double((try? c.decode(Int.self, forKey: .fat)) ?? 65)
|
||||
sugar = (try? c.decode(Double.self, forKey: .sugar)) ?? Double((try? c.decode(Int.self, forKey: .sugar)) ?? 50)
|
||||
fiber = (try? c.decode(Double.self, forKey: .fiber)) ?? Double((try? c.decode(Int.self, forKey: .fiber)) ?? 30)
|
||||
let c = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||
id = try? c.decode(String.self, forKey: AnyCodingKey("id"))
|
||||
calories = Self.doubleFlex(c, "calories", default: 2000)
|
||||
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)
|
||||
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")) {
|
||||
isActive = Int(v)
|
||||
} else {
|
||||
isActive = nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func doubleFlex(_ c: KeyedDecodingContainer<AnyCodingKey>, _ key: String, default def: Double) -> 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 def
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Meal Template
|
||||
// API: id (UUID), name, meal_type, total_calories, total_protein, total_carbs, total_fat, item_count
|
||||
|
||||
struct MealTemplate: Identifiable, Codable {
|
||||
let id: Int
|
||||
let id: String
|
||||
let name: String
|
||||
let mealType: MealType
|
||||
let totalCalories: Double?
|
||||
@@ -209,35 +261,45 @@ struct MealTemplate: Identifiable, Codable {
|
||||
let totalFat: Double?
|
||||
let itemCount: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name
|
||||
case mealType = "meal_type"
|
||||
case totalCalories = "total_calories"
|
||||
case totalProtein = "total_protein"
|
||||
case totalCarbs = "total_carbs"
|
||||
case totalFat = "total_fat"
|
||||
case itemCount = "item_count"
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||
if let v = try? c.decode(String.self, forKey: AnyCodingKey("id")) {
|
||||
id = v
|
||||
} else if let v = try? c.decode(Int.self, forKey: AnyCodingKey("id")) {
|
||||
id = String(v)
|
||||
} else {
|
||||
id = ""
|
||||
}
|
||||
name = (try? c.decode(String.self, forKey: AnyCodingKey("name"))) ?? ""
|
||||
mealType = (try? c.decode(MealType.self, forKey: AnyCodingKey("mealType"))) ?? .snack
|
||||
totalCalories = Self.doubleFlexOpt(c, "totalCalories")
|
||||
totalProtein = Self.doubleFlexOpt(c, "totalProtein")
|
||||
totalCarbs = Self.doubleFlexOpt(c, "totalCarbs")
|
||||
totalFat = Self.doubleFlexOpt(c, "totalFat")
|
||||
if let v = try? c.decode(Int.self, forKey: AnyCodingKey("itemCount")) {
|
||||
itemCount = v
|
||||
} else if let v = try? c.decode(Double.self, forKey: AnyCodingKey("itemCount")) {
|
||||
itemCount = Int(v)
|
||||
} else {
|
||||
itemCount = nil
|
||||
}
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
if let v = try? c.decode(Int.self, forKey: .id) { id = v }
|
||||
else if let v = try? c.decode(Double.self, forKey: .id) { id = Int(v) }
|
||||
else { id = 0 }
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
mealType = try c.decode(MealType.self, forKey: .mealType)
|
||||
totalCalories = (try? c.decode(Double.self, forKey: .totalCalories)) ?? (try? c.decode(Int.self, forKey: .totalCalories)).map { Double($0) }
|
||||
totalProtein = (try? c.decode(Double.self, forKey: .totalProtein)) ?? (try? c.decode(Int.self, forKey: .totalProtein)).map { Double($0) }
|
||||
totalCarbs = (try? c.decode(Double.self, forKey: .totalCarbs)) ?? (try? c.decode(Int.self, forKey: .totalCarbs)).map { Double($0) }
|
||||
totalFat = (try? c.decode(Double.self, forKey: .totalFat)) ?? (try? c.decode(Int.self, forKey: .totalFat)).map { Double($0) }
|
||||
itemCount = (try? c.decode(Int.self, forKey: .itemCount)) ?? (try? c.decode(Double.self, forKey: .itemCount)).map { Int($0) }
|
||||
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: - Requests
|
||||
// Note: APIClient uses encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
// so camelCase properties are auto-converted to snake_case in JSON.
|
||||
|
||||
struct CreateEntryRequest: Encodable {
|
||||
let foodId: Int?
|
||||
let foodId: String?
|
||||
let foodName: String
|
||||
let mealType: String
|
||||
let quantity: Double
|
||||
@@ -248,15 +310,6 @@ struct CreateEntryRequest: Encodable {
|
||||
let fat: Double
|
||||
let sugar: Double?
|
||||
let fiber: Double?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case foodId = "food_id"
|
||||
case foodName = "food_name"
|
||||
case mealType = "meal_type"
|
||||
case quantity
|
||||
case entryDate = "entry_date"
|
||||
case calories, protein, carbs, fat, sugar, fiber
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdateGoalsRequest: Encodable {
|
||||
@@ -326,3 +379,25 @@ struct SourceLink: Identifiable {
|
||||
self.href = (dict["href"] as? String) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AnyCodingKey (flexible key lookup)
|
||||
|
||||
struct AnyCodingKey: CodingKey {
|
||||
var stringValue: String
|
||||
var intValue: Int?
|
||||
|
||||
init(_ string: String) {
|
||||
self.stringValue = string
|
||||
self.intValue = nil
|
||||
}
|
||||
|
||||
init?(stringValue: String) {
|
||||
self.stringValue = stringValue
|
||||
self.intValue = nil
|
||||
}
|
||||
|
||||
init?(intValue: Int) {
|
||||
self.stringValue = String(intValue)
|
||||
self.intValue = intValue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ final class FitnessRepository {
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func deleteEntry(id: Int) async {
|
||||
func deleteEntry(id: String) async {
|
||||
do {
|
||||
try await api.deleteEntry(id: id)
|
||||
entries.removeAll { $0.id == id }
|
||||
@@ -43,7 +43,7 @@ final class FitnessRepository {
|
||||
}
|
||||
}
|
||||
|
||||
func updateEntry(id: Int, quantity: Double) async -> FoodEntry? {
|
||||
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 }) {
|
||||
|
||||
@@ -60,7 +60,7 @@ struct AddFoodSheet: View {
|
||||
Button { if quantity > 0.5 { quantity -= 0.5 } } label: {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accent)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
Text(String(format: "%.1f", quantity))
|
||||
.font(.title2.bold())
|
||||
@@ -68,7 +68,7 @@ struct AddFoodSheet: View {
|
||||
Button { quantity += 0.5 } label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accent)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,7 @@ struct AddFoodSheet: View {
|
||||
.frame(height: 48)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.accent)
|
||||
.tint(Color.accentWarm)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.padding(20)
|
||||
|
||||
@@ -51,7 +51,7 @@ struct EntryDetailView: View {
|
||||
Button { if quantity > 0.5 { quantity -= 0.5 } } label: {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accent)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
Text(String(format: "%.1f", quantity))
|
||||
.font(.title2.bold())
|
||||
@@ -59,7 +59,7 @@ struct EntryDetailView: View {
|
||||
Button { quantity += 0.5 } label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accent)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ struct EntryDetailView: View {
|
||||
.frame(height: 44)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.accent)
|
||||
.tint(Color.accentWarm)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
|
||||
@@ -27,12 +27,12 @@ struct FitnessTabView: View {
|
||||
Text(tab.rawValue)
|
||||
.font(.subheadline)
|
||||
.fontWeight(selectedTab == tab ? .bold : .medium)
|
||||
.foregroundStyle(selectedTab == tab ? Color.accent : Color.textSecondary)
|
||||
.foregroundStyle(selectedTab == tab ? Color.accentWarm : Color.textSecondary)
|
||||
.padding(.bottom, 8)
|
||||
.overlay(alignment: .bottom) {
|
||||
if selectedTab == tab {
|
||||
Rectangle()
|
||||
.fill(Color.accent)
|
||||
.fill(Color.accentWarm)
|
||||
.frame(height: 2)
|
||||
}
|
||||
}
|
||||
@@ -64,9 +64,9 @@ struct FitnessTabView: View {
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(Color.accent)
|
||||
.background(Color.accentWarm)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: Color.accent.opacity(0.3), radius: 8, y: 4)
|
||||
.shadow(color: Color.accentWarm.opacity(0.3), radius: 8, y: 4)
|
||||
}
|
||||
.padding(.trailing, 20)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
@@ -112,7 +112,7 @@ struct FoodSearchView: View {
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "plus.circle")
|
||||
.foregroundStyle(Color.accent)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ struct GoalsView: View {
|
||||
.frame(height: 48)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.accent)
|
||||
.tint(Color.accentWarm)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.disabled(vm.isSaving)
|
||||
|
||||
@@ -90,7 +90,7 @@ struct TemplatesView: View {
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.accent)
|
||||
.background(Color.accentWarm)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ struct TodayView: View {
|
||||
Button { viewModel.previousDay() } label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.accent)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
Spacer()
|
||||
Text(viewModel.displayDate)
|
||||
@@ -22,7 +22,7 @@ struct TodayView: View {
|
||||
Button { viewModel.nextDay() } label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.accent)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
@@ -58,7 +58,7 @@ struct HomeView: View {
|
||||
} label: {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(vm.backgroundImage != nil ? .white : Color.accent)
|
||||
.foregroundStyle(vm.backgroundImage != nil ? .white : Color.accentWarm)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
@@ -83,9 +83,9 @@ struct HomeView: View {
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(Color.accent)
|
||||
.background(Color.accentWarm)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: Color.accent.opacity(0.3), radius: 8, y: 4)
|
||||
.shadow(color: Color.accentWarm.opacity(0.3), radius: 8, y: 4)
|
||||
}
|
||||
.padding(.trailing, 20)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
@@ -6,7 +6,7 @@ struct LoadingView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
.tint(.accent)
|
||||
.tint(.accentWarm)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
@@ -32,7 +32,7 @@ struct ErrorBanner: View {
|
||||
Task { await retry() }
|
||||
}
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.accent)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
|
||||
@@ -27,8 +27,8 @@ extension Color {
|
||||
static let canvas = Color(hex: "F5EFE6")
|
||||
static let surface = Color(hex: "FFFFFF")
|
||||
static let surfaceSecondary = Color(hex: "FAF7F2")
|
||||
static let accent = Color(hex: "8B6914")
|
||||
static let accentLight = Color(hex: "D4A843")
|
||||
static let accentWarm = Color(hex: "8B6914")
|
||||
static let accentWarmLight = Color(hex: "D4A843")
|
||||
static let emerald = Color(hex: "059669")
|
||||
static let textPrimary = Color(hex: "1C1917")
|
||||
static let textSecondary = Color(hex: "78716C")
|
||||
|
||||
Reference in New Issue
Block a user