feat: sugar/fiber macros, editable goals, keyboard dismiss, entry editing
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 3s

1. Sugar + Fiber bars in TodayView macro summary card
2. Goals page: editable text fields + Save button (PUT /api/fitness/goals)
3. AI Chat: keyboard dismisses on send + tap chat area to dismiss
4. Entry detail: edit quantity (stepper) + meal type picker + Save

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 11:49:07 -05:00
parent 7f549cd6a0
commit 85e0d07224
8 changed files with 303 additions and 28 deletions

View File

@@ -68,6 +68,9 @@ struct AssistantChatView: View {
} }
.padding(.vertical, 8) .padding(.vertical, 8)
} }
.onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
.onChange(of: vm.messages.count) { .onChange(of: vm.messages.count) {
if let last = vm.messages.last { if let last = vm.messages.last {
withAnimation { withAnimation {
@@ -90,10 +93,12 @@ struct AssistantChatView: View {
TextField("Describe your food...", text: $vm.inputText) TextField("Describe your food...", text: $vm.inputText)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.onSubmit { .onSubmit {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
Task { await vm.send() } Task { await vm.send() }
} }
Button { Button {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
Task { await vm.send() } Task { await vm.send() }
} label: { } label: {
Image(systemName: "arrow.up.circle.fill") Image(systemName: "arrow.up.circle.fill")

View File

@@ -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 // MARK: - Foods
func getFoods(limit: Int = 100) async throws -> [Food] { func getFoods(limit: Int = 100) async throws -> [Food] {

View File

@@ -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 // MARK: - Delete Response
struct SuccessResponse: Decodable { struct SuccessResponse: Decodable {

View File

@@ -6,15 +6,59 @@ final class GoalsViewModel {
var goal: DailyGoal? var goal: DailyGoal?
var isLoading = false var isLoading = false
var isSaving = false
var error: String? 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 { func load() async {
isLoading = true isLoading = true
do { do {
goal = try await api.getGoalsForDate(date: Date().apiDateString) let loaded = try await api.getGoalsForDate(date: Date().apiDateString)
goal = loaded
populateFields(from: loaded)
} catch { } catch {
self.error = "No active goal found" self.error = "No active goal found"
} }
isLoading = false 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))"
}
} }

View File

@@ -55,6 +55,14 @@ final class TodayViewModel {
entries.reduce(0) { $0 + $1.fat } 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 { var calorieGoal: Double {
goal?.calories ?? 2000 goal?.calories ?? 2000
} }
@@ -71,6 +79,14 @@ final class TodayViewModel {
goal?.fat ?? 65 goal?.fat ?? 65
} }
var sugarGoal: Double {
goal?.sugar ?? 50
}
var fiberGoal: Double {
goal?.fiber ?? 30
}
// MARK: - Actions // MARK: - Actions
func load() async { func load() async {

View File

@@ -3,8 +3,24 @@ import SwiftUI
struct EntryDetailView: View { struct EntryDetailView: View {
let entry: FoodEntry let entry: FoodEntry
let onDelete: () -> Void let onDelete: () -> Void
var onUpdate: (() -> Void)?
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var showDeleteConfirmation = false @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 { var body: some View {
ScrollView { ScrollView {
@@ -21,19 +37,112 @@ struct EntryDetailView: View {
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Color.textSecondary) .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) .frame(maxWidth: .infinity)
.padding() .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 // Nutrition grid
LazyVGrid(columns: [ LazyVGrid(columns: [
GridItem(.flexible()), GridItem(.flexible()),
@@ -54,7 +163,6 @@ struct EntryDetailView: View {
// Metadata // Metadata
VStack(spacing: 8) { VStack(spacing: 8) {
metadataRow("Date", value: entry.entryDate) metadataRow("Date", value: entry.entryDate)
metadataRow("Quantity", value: "\(String(format: "%.1f", entry.quantity)) \(entry.unit)")
metadataRow("Source", value: entry.source) metadataRow("Source", value: entry.source)
metadataRow("Method", value: entry.entryMethod) metadataRow("Method", value: entry.entryMethod)
if let note = entry.note, !note.isEmpty { 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 { private func nutritionCell(_ label: String, value: Double, unit: String, color: Color) -> some View {
VStack(spacing: 4) { VStack(spacing: 4) {
Text("\(Int(value))") Text("\(Int(value))")

View File

@@ -8,8 +8,8 @@ struct GoalsView: View {
VStack(spacing: 16) { VStack(spacing: 16) {
if vm.isLoading { if vm.isLoading {
LoadingView() LoadingView()
} else if let goal = vm.goal { } else if vm.goal != nil {
goalCard(goal) goalEditor
} else { } else {
EmptyStateView( EmptyStateView(
icon: "target", icon: "target",
@@ -32,7 +32,7 @@ struct GoalsView: View {
} }
} }
private func goalCard(_ goal: DailyGoal) -> some View { private var goalEditor: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
Text("Daily Goals") Text("Daily Goals")
.font(.headline) .font(.headline)
@@ -43,14 +43,15 @@ struct GoalsView: View {
GridItem(.flexible()), GridItem(.flexible()),
GridItem(.flexible()), GridItem(.flexible()),
], spacing: 16) { ], spacing: 16) {
goalItem("Calories", value: goal.calories, unit: "kcal", color: .emerald) goalField("Calories", text: $vm.editCalories, unit: "kcal", color: .emerald)
goalItem("Protein", value: goal.protein, unit: "g", color: .macroProtein) goalField("Protein", text: $vm.editProtein, unit: "g", color: .macroProtein)
goalItem("Carbs", value: goal.carbs, unit: "g", color: .macroCarbs) goalField("Carbs", text: $vm.editCarbs, unit: "g", color: .macroCarbs)
goalItem("Fat", value: goal.fat, unit: "g", color: .macroFat) goalField("Fat", text: $vm.editFat, unit: "g", color: .macroFat)
goalItem("Sugar", value: goal.sugar, unit: "g", color: .orange) goalField("Sugar", text: $vm.editSugar, unit: "g", color: .orange)
goalItem("Fiber", value: goal.fiber, unit: "g", color: .green) goalField("Fiber", text: $vm.editFiber, unit: "g", color: .green)
} }
if let goal = vm.goal {
HStack { HStack {
Text("Active since \(goal.startDate)") Text("Active since \(goal.startDate)")
.font(.caption) .font(.caption)
@@ -58,18 +59,58 @@ struct GoalsView: View {
Spacer() 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() .padding()
.background(Color.surfaceCard) .background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 14)) .clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.04), radius: 6, y: 2) .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<String>, unit: String, color: Color) -> some View {
VStack(spacing: 6) { VStack(spacing: 6) {
Text("\(Int(value))") TextField("0", text: text)
.font(.title2.weight(.bold).monospacedDigit()) .font(.title2.weight(.bold).monospacedDigit())
.foregroundStyle(color) .foregroundStyle(color)
Text("\(unit)") .multilineTextAlignment(.center)
.keyboardType(.numberPad)
Text(unit)
.font(.caption2) .font(.caption2)
.foregroundStyle(Color.textTertiary) .foregroundStyle(Color.textTertiary)
Text(label) Text(label)

View File

@@ -112,6 +112,18 @@ struct TodayView: View {
goal: vm.fatGoal, goal: vm.fatGoal,
color: .macroFat color: .macroFat
) )
MacroBar(
label: "Sugar",
consumed: vm.totalSugar,
goal: vm.sugarGoal,
color: .orange
)
MacroBar(
label: "Fiber",
consumed: vm.totalFiber,
goal: vm.fiberGoal,
color: .green
)
} }
} }
} }