diff --git a/ios/Platform/Platform/Features/Assistant/AssistantChatView.swift b/ios/Platform/Platform/Features/Assistant/AssistantChatView.swift index f403d13..eb80eff 100644 --- a/ios/Platform/Platform/Features/Assistant/AssistantChatView.swift +++ b/ios/Platform/Platform/Features/Assistant/AssistantChatView.swift @@ -68,6 +68,9 @@ struct AssistantChatView: View { } .padding(.vertical, 8) } + .onTapGesture { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } .onChange(of: vm.messages.count) { if let last = vm.messages.last { withAnimation { @@ -90,10 +93,12 @@ struct AssistantChatView: View { TextField("Describe your food...", text: $vm.inputText) .textFieldStyle(.plain) .onSubmit { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) Task { await vm.send() } } Button { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) Task { await vm.send() } } label: { Image(systemName: "arrow.up.circle.fill") diff --git a/ios/Platform/Platform/Features/Fitness/API/FitnessAPI.swift b/ios/Platform/Platform/Features/Fitness/API/FitnessAPI.swift index cb9ee58..b979ea4 100644 --- a/ios/Platform/Platform/Features/Fitness/API/FitnessAPI.swift +++ b/ios/Platform/Platform/Features/Fitness/API/FitnessAPI.swift @@ -30,6 +30,14 @@ struct FitnessAPI { ) } + func updateGoals(_ request: UpdateGoalsRequest) async throws -> DailyGoal { + try await api.put("\(basePath)/goals", body: request) + } + + func updateEntry(id: String, body: UpdateEntryRequest) async throws -> FoodEntry { + try await api.patch("\(basePath)/entries/\(id)", body: body) + } + // MARK: - Foods func getFoods(limit: Int = 100) async throws -> [Food] { diff --git a/ios/Platform/Platform/Features/Fitness/Models/FitnessModels.swift b/ios/Platform/Platform/Features/Fitness/Models/FitnessModels.swift index e8c379a..ca5974d 100644 --- a/ios/Platform/Platform/Features/Fitness/Models/FitnessModels.swift +++ b/ios/Platform/Platform/Features/Fitness/Models/FitnessModels.swift @@ -212,6 +212,29 @@ struct CreateEntryRequest: Encodable { } } +// MARK: - Update Goals Request + +struct UpdateGoalsRequest: Encodable { + let calories: Double + let protein: Double + let carbs: Double + let fat: Double + let sugar: Double + let fiber: Double +} + +// MARK: - Update Entry Request + +struct UpdateEntryRequest: Encodable { + let quantity: Double? + let mealType: String? + + enum CodingKeys: String, CodingKey { + case quantity + case mealType = "meal_type" + } +} + // MARK: - Delete Response struct SuccessResponse: Decodable { diff --git a/ios/Platform/Platform/Features/Fitness/ViewModels/GoalsViewModel.swift b/ios/Platform/Platform/Features/Fitness/ViewModels/GoalsViewModel.swift index acc87b3..3516067 100644 --- a/ios/Platform/Platform/Features/Fitness/ViewModels/GoalsViewModel.swift +++ b/ios/Platform/Platform/Features/Fitness/ViewModels/GoalsViewModel.swift @@ -6,15 +6,59 @@ final class GoalsViewModel { var goal: DailyGoal? var isLoading = false + var isSaving = false var error: String? + var saveSuccess = false + + // Editable fields + var editCalories: String = "" + var editProtein: String = "" + var editCarbs: String = "" + var editFat: String = "" + var editSugar: String = "" + var editFiber: String = "" func load() async { isLoading = true do { - goal = try await api.getGoalsForDate(date: Date().apiDateString) + let loaded = try await api.getGoalsForDate(date: Date().apiDateString) + goal = loaded + populateFields(from: loaded) } catch { self.error = "No active goal found" } isLoading = false } + + func save() async { + isSaving = true + saveSuccess = false + error = nil + do { + let request = UpdateGoalsRequest( + calories: Double(editCalories) ?? 0, + protein: Double(editProtein) ?? 0, + carbs: Double(editCarbs) ?? 0, + fat: Double(editFat) ?? 0, + sugar: Double(editSugar) ?? 0, + fiber: Double(editFiber) ?? 0 + ) + let updated = try await api.updateGoals(request) + goal = updated + populateFields(from: updated) + saveSuccess = true + } catch { + self.error = error.localizedDescription + } + isSaving = false + } + + private func populateFields(from goal: DailyGoal) { + editCalories = "\(Int(goal.calories))" + editProtein = "\(Int(goal.protein))" + editCarbs = "\(Int(goal.carbs))" + editFat = "\(Int(goal.fat))" + editSugar = "\(Int(goal.sugar))" + editFiber = "\(Int(goal.fiber))" + } } diff --git a/ios/Platform/Platform/Features/Fitness/ViewModels/TodayViewModel.swift b/ios/Platform/Platform/Features/Fitness/ViewModels/TodayViewModel.swift index 8044a2e..a52de32 100644 --- a/ios/Platform/Platform/Features/Fitness/ViewModels/TodayViewModel.swift +++ b/ios/Platform/Platform/Features/Fitness/ViewModels/TodayViewModel.swift @@ -55,6 +55,14 @@ final class TodayViewModel { entries.reduce(0) { $0 + $1.fat } } + var totalSugar: Double { + entries.reduce(0) { $0 + $1.sugar } + } + + var totalFiber: Double { + entries.reduce(0) { $0 + $1.fiber } + } + var calorieGoal: Double { goal?.calories ?? 2000 } @@ -71,6 +79,14 @@ final class TodayViewModel { goal?.fat ?? 65 } + var sugarGoal: Double { + goal?.sugar ?? 50 + } + + var fiberGoal: Double { + goal?.fiber ?? 30 + } + // MARK: - Actions func load() async { diff --git a/ios/Platform/Platform/Features/Fitness/Views/EntryDetailView.swift b/ios/Platform/Platform/Features/Fitness/Views/EntryDetailView.swift index bc360d0..d947568 100644 --- a/ios/Platform/Platform/Features/Fitness/Views/EntryDetailView.swift +++ b/ios/Platform/Platform/Features/Fitness/Views/EntryDetailView.swift @@ -3,8 +3,24 @@ import SwiftUI struct EntryDetailView: View { let entry: FoodEntry let onDelete: () -> Void + var onUpdate: (() -> Void)? @Environment(\.dismiss) private var dismiss @State private var showDeleteConfirmation = false + @State private var editQuantity: String + @State private var editMealType: MealType + @State private var isSaving = false + @State private var saveError: String? + @State private var saveSuccess = false + + private let api = FitnessAPI() + + init(entry: FoodEntry, onDelete: @escaping () -> Void, onUpdate: (() -> Void)? = nil) { + self.entry = entry + self.onDelete = onDelete + self.onUpdate = onUpdate + _editQuantity = State(initialValue: String(format: "%.1f", entry.quantity)) + _editMealType = State(initialValue: MealType(rawValue: entry.mealType) ?? .snack) + } var body: some View { ScrollView { @@ -21,19 +37,112 @@ struct EntryDetailView: View { .font(.subheadline) .foregroundStyle(Color.textSecondary) } - - HStack(spacing: 8) { - Image(systemName: (MealType(rawValue: entry.mealType) ?? .snack).icon) - .foregroundStyle(Color.mealColor(for: entry.mealType)) - Text(entry.mealType.capitalized) - .font(.subheadline.weight(.medium)) - .foregroundStyle(Color.mealColor(for: entry.mealType)) - } - .padding(.top, 4) } .frame(maxWidth: .infinity) .padding() + // Editable fields + VStack(spacing: 16) { + Text("Edit Entry") + .font(.headline) + .foregroundStyle(Color.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + + // Meal type picker + VStack(alignment: .leading, spacing: 8) { + Text("Meal") + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.textSecondary) + Picker("Meal Type", selection: $editMealType) { + ForEach(MealType.allCases, id: \.self) { meal in + Label(meal.displayName, systemImage: meal.icon) + .tag(meal) + } + } + .pickerStyle(.segmented) + } + + // Quantity field + VStack(alignment: .leading, spacing: 8) { + Text("Quantity (\(entry.unit))") + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.textSecondary) + HStack(spacing: 12) { + Button { + if let val = Double(editQuantity), val > 0.5 { + editQuantity = String(format: "%.1f", val - 0.5) + } + } label: { + Image(systemName: "minus.circle.fill") + .font(.title2) + .foregroundStyle(Color.accentWarm) + } + + TextField("1.0", text: $editQuantity) + .font(.title3.weight(.semibold).monospacedDigit()) + .foregroundStyle(Color.textPrimary) + .multilineTextAlignment(.center) + .keyboardType(.decimalPad) + .frame(width: 80) + .padding(.vertical, 8) + .background(Color.canvas) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + Button { + if let val = Double(editQuantity) { + editQuantity = String(format: "%.1f", val + 0.5) + } + } label: { + Image(systemName: "plus.circle.fill") + .font(.title2) + .foregroundStyle(Color.accentWarm) + } + } + .frame(maxWidth: .infinity) + } + + if saveSuccess { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.emerald) + Text("Entry updated") + .font(.caption.weight(.medium)) + .foregroundStyle(Color.emerald) + Spacer() + } + } + + if let error = saveError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + Button { + Task { await saveChanges() } + } label: { + HStack { + if isSaving { + ProgressView() + .controlSize(.small) + .tint(.white) + } + Text("Save Changes") + .font(.subheadline.weight(.semibold)) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.emerald) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .disabled(isSaving) + } + .padding() + .background(Color.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(0.04), radius: 6, y: 2) + // Nutrition grid LazyVGrid(columns: [ GridItem(.flexible()), @@ -54,7 +163,6 @@ struct EntryDetailView: View { // Metadata VStack(spacing: 8) { metadataRow("Date", value: entry.entryDate) - metadataRow("Quantity", value: "\(String(format: "%.1f", entry.quantity)) \(entry.unit)") metadataRow("Source", value: entry.source) metadataRow("Method", value: entry.entryMethod) if let note = entry.note, !note.isEmpty { @@ -93,6 +201,24 @@ struct EntryDetailView: View { } } + private func saveChanges() async { + isSaving = true + saveError = nil + saveSuccess = false + do { + let request = UpdateEntryRequest( + quantity: Double(editQuantity), + mealType: editMealType.rawValue + ) + _ = try await api.updateEntry(id: entry.id, body: request) + saveSuccess = true + onUpdate?() + } catch { + saveError = error.localizedDescription + } + isSaving = false + } + private func nutritionCell(_ label: String, value: Double, unit: String, color: Color) -> some View { VStack(spacing: 4) { Text("\(Int(value))") diff --git a/ios/Platform/Platform/Features/Fitness/Views/GoalsView.swift b/ios/Platform/Platform/Features/Fitness/Views/GoalsView.swift index ef545e4..f1a47f0 100644 --- a/ios/Platform/Platform/Features/Fitness/Views/GoalsView.swift +++ b/ios/Platform/Platform/Features/Fitness/Views/GoalsView.swift @@ -8,8 +8,8 @@ struct GoalsView: View { VStack(spacing: 16) { if vm.isLoading { LoadingView() - } else if let goal = vm.goal { - goalCard(goal) + } else if vm.goal != nil { + goalEditor } else { EmptyStateView( icon: "target", @@ -32,7 +32,7 @@ struct GoalsView: View { } } - private func goalCard(_ goal: DailyGoal) -> some View { + private var goalEditor: some View { VStack(spacing: 16) { Text("Daily Goals") .font(.headline) @@ -43,20 +43,59 @@ struct GoalsView: View { GridItem(.flexible()), GridItem(.flexible()), ], spacing: 16) { - goalItem("Calories", value: goal.calories, unit: "kcal", color: .emerald) - goalItem("Protein", value: goal.protein, unit: "g", color: .macroProtein) - goalItem("Carbs", value: goal.carbs, unit: "g", color: .macroCarbs) - goalItem("Fat", value: goal.fat, unit: "g", color: .macroFat) - goalItem("Sugar", value: goal.sugar, unit: "g", color: .orange) - goalItem("Fiber", value: goal.fiber, unit: "g", color: .green) + goalField("Calories", text: $vm.editCalories, unit: "kcal", color: .emerald) + goalField("Protein", text: $vm.editProtein, unit: "g", color: .macroProtein) + goalField("Carbs", text: $vm.editCarbs, unit: "g", color: .macroCarbs) + goalField("Fat", text: $vm.editFat, unit: "g", color: .macroFat) + goalField("Sugar", text: $vm.editSugar, unit: "g", color: .orange) + goalField("Fiber", text: $vm.editFiber, unit: "g", color: .green) } - HStack { - Text("Active since \(goal.startDate)") - .font(.caption) - .foregroundStyle(Color.textTertiary) - Spacer() + if let goal = vm.goal { + HStack { + Text("Active since \(goal.startDate)") + .font(.caption) + .foregroundStyle(Color.textTertiary) + Spacer() + } } + + if vm.saveSuccess { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.emerald) + Text("Goals saved") + .font(.caption.weight(.medium)) + .foregroundStyle(Color.emerald) + Spacer() + } + } + + if let error = vm.error { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + Button { + Task { await vm.save() } + } label: { + HStack { + if vm.isSaving { + ProgressView() + .controlSize(.small) + .tint(.white) + } + Text("Save Goals") + .font(.subheadline.weight(.semibold)) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.emerald) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .disabled(vm.isSaving) } .padding() .background(Color.surfaceCard) @@ -64,12 +103,14 @@ struct GoalsView: View { .shadow(color: .black.opacity(0.04), radius: 6, y: 2) } - private func goalItem(_ label: String, value: Double, unit: String, color: Color) -> some View { + private func goalField(_ label: String, text: Binding, unit: String, color: Color) -> some View { VStack(spacing: 6) { - Text("\(Int(value))") + TextField("0", text: text) .font(.title2.weight(.bold).monospacedDigit()) .foregroundStyle(color) - Text("\(unit)") + .multilineTextAlignment(.center) + .keyboardType(.numberPad) + Text(unit) .font(.caption2) .foregroundStyle(Color.textTertiary) Text(label) diff --git a/ios/Platform/Platform/Features/Fitness/Views/TodayView.swift b/ios/Platform/Platform/Features/Fitness/Views/TodayView.swift index 07f9d5f..dc9c4a9 100644 --- a/ios/Platform/Platform/Features/Fitness/Views/TodayView.swift +++ b/ios/Platform/Platform/Features/Fitness/Views/TodayView.swift @@ -112,6 +112,18 @@ struct TodayView: View { goal: vm.fatGoal, color: .macroFat ) + MacroBar( + label: "Sugar", + consumed: vm.totalSugar, + goal: vm.sugarGoal, + color: .orange + ) + MacroBar( + label: "Fiber", + consumed: vm.totalFiber, + goal: vm.fiberGoal, + color: .green + ) } } }