feat: sugar/fiber macros, editable goals, keyboard dismiss, entry editing
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:
@@ -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")
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))")
|
||||
|
||||
@@ -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<String>, 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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user