feat: editable AI draft card — edit food/macros before adding (#16)
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 4s

- FitnessDraft properties changed from let to var (mutable)
- New EditableDraftCard with Edit/Done toggle:
  - Editable food name
  - Editable macros (calories, protein, carbs, fat, sugar, fiber)
  - Meal type picker (dropdown menu)
  - Editable quantity
- Edited values flow through draftToDict → apply endpoint
- No backend changes needed — purely iOS UI
- Default view is read-only (same as before), tap Edit to modify

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 20:53:25 -05:00
parent a0d3f24614
commit 11fd59e88f
4 changed files with 221 additions and 58 deletions

View File

@@ -13,6 +13,7 @@
A10004 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10004 /* APIClient.swift */; };
A10005 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10005 /* AuthManager.swift */; };
A10050 /* AppearanceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10050 /* AppearanceManager.swift */; };
A10051 /* EditableDraftCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10051 /* EditableDraftCard.swift */; };
A10006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10006 /* LoginView.swift */; };
A10007 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10007 /* HomeView.swift */; };
A10008 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10008 /* HomeViewModel.swift */; };
@@ -59,6 +60,7 @@
B10004 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
B10005 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
B10050 /* AppearanceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceManager.swift; sourceTree = "<group>"; };
B10051 /* EditableDraftCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableDraftCard.swift; sourceTree = "<group>"; };
B10006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
B10007 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
B10008 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
@@ -242,6 +244,7 @@
children = (
B10025 /* AssistantChatView.swift */,
B10026 /* AssistantViewModel.swift */,
B10051 /* EditableDraftCard.swift */,
);
path = Assistant;
sourceTree = "<group>";
@@ -415,6 +418,7 @@
A10004 /* APIClient.swift in Sources */,
A10005 /* AuthManager.swift in Sources */,
A10050 /* AppearanceManager.swift in Sources */,
A10051 /* EditableDraftCard.swift in Sources */,
A10006 /* LoginView.swift in Sources */,
A10007 /* HomeView.swift in Sources */,
A10008 /* HomeViewModel.swift in Sources */,

View File

@@ -151,53 +151,10 @@ struct AssistantChatView: View {
.padding(.horizontal, 12)
}
// MARK: - Draft Card
// MARK: - Draft Card (Editable)
private func draftCard(_ draft: FitnessDraft) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "doc.text.fill")
.foregroundStyle(Color.accentWarm)
Text("Draft")
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.textPrimary)
}
Text(draft.foodName)
.font(.headline)
.foregroundStyle(Color.textPrimary)
HStack(spacing: 16) {
miniStat("Cal", value: Int(draft.calories))
miniStat("P", value: Int(draft.protein))
miniStat("C", value: Int(draft.carbs))
miniStat("F", value: Int(draft.fat))
}
HStack {
Text("\(draft.mealType.capitalized) \u{2022} \(draft.quantity, specifier: "%.1f") \(draft.unit)")
.font(.caption)
.foregroundStyle(Color.textSecondary)
Spacer()
}
Button {
Task { await vm.applyDraft() }
} label: {
Text("Add it")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.emerald)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
.padding()
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.06), radius: 6, y: 2)
.padding(.horizontal, 12)
EditableDraftCard(vm: vm)
}
private var multipleDraftsCard: some View {

View File

@@ -0,0 +1,202 @@
import SwiftUI
struct EditableDraftCard: View {
@Bindable var vm: AssistantViewModel
@State private var isEditing = false
private var draft: FitnessDraft? { vm.currentDraft }
var body: some View {
if let draft {
VStack(alignment: .leading, spacing: 10) {
// Header
HStack {
Image(systemName: "doc.text.fill")
.foregroundStyle(Color.accentWarm)
Text("Draft")
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.textPrimary)
Spacer()
Button {
withAnimation(.easeInOut(duration: 0.2)) { isEditing.toggle() }
} label: {
Text(isEditing ? "Done" : "Edit")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.accentWarm)
}
}
// Food name
if isEditing {
TextField("Food name", text: binding(\.foodName))
.font(.headline)
.foregroundStyle(Color.textPrimary)
.textFieldStyle(.plain)
} else {
Text(draft.foodName)
.font(.headline)
.foregroundStyle(Color.textPrimary)
}
// Macros editable or static
if isEditing {
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
], spacing: 10) {
editableMacro("Cal", value: binding(\.calories), color: .emerald)
editableMacro("Protein", value: binding(\.protein), color: .macroProtein)
editableMacro("Carbs", value: binding(\.carbs), color: .macroCarbs)
editableMacro("Fat", value: binding(\.fat), color: .macroFat)
editableMacro("Sugar", value: binding(\.sugar), color: .orange)
editableMacro("Fiber", value: binding(\.fiber), color: .green)
}
} else {
HStack(spacing: 16) {
miniStat("Cal", value: Int(draft.calories))
miniStat("P", value: Int(draft.protein))
miniStat("C", value: Int(draft.carbs))
miniStat("F", value: Int(draft.fat))
}
}
// Meal type + quantity
if isEditing {
HStack(spacing: 12) {
// Meal picker
Menu {
ForEach(MealType.allCases, id: \.self) { meal in
Button {
vm.currentDraft?.mealType = meal.rawValue
} label: {
Label(meal.displayName, systemImage: meal.icon)
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: MealType(rawValue: draft.mealType)?.icon ?? "fork.knife")
.font(.caption)
Text(draft.mealType.capitalized)
.font(.caption.weight(.medium))
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8))
}
.foregroundStyle(Color.accentWarm)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.accentWarm.opacity(0.1))
.clipShape(Capsule())
}
// Quantity
HStack(spacing: 6) {
Text("Qty:")
.font(.caption)
.foregroundStyle(Color.textSecondary)
TextField("1.0", text: quantityBinding)
.font(.caption.weight(.semibold).monospacedDigit())
.foregroundStyle(Color.textPrimary)
.keyboardType(.decimalPad)
.frame(width: 40)
.multilineTextAlignment(.center)
Text(draft.unit)
.font(.caption)
.foregroundStyle(Color.textTertiary)
}
Spacer()
}
} else {
HStack {
Text("\(draft.mealType.capitalized) \u{2022} \(draft.quantity, specifier: "%.1f") \(draft.unit)")
.font(.caption)
.foregroundStyle(Color.textSecondary)
Spacer()
}
}
// Add button
Button {
isEditing = false
Task { await vm.applyDraft() }
} label: {
Text("Add it")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.emerald)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
.padding()
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.06), radius: 6, y: 2)
.padding(.horizontal, 12)
}
}
// MARK: - Editable macro cell
private func editableMacro(_ label: String, value: Binding<Double>, color: Color) -> some View {
VStack(spacing: 2) {
TextField("0", text: doubleBinding(value))
.font(.caption.weight(.bold).monospacedDigit())
.foregroundStyle(color)
.multilineTextAlignment(.center)
.keyboardType(.numberPad)
.frame(height: 24)
Text(label)
.font(.system(size: 9).weight(.medium))
.foregroundStyle(Color.textTertiary)
}
.padding(.vertical, 4)
.background(color.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
// MARK: - Static mini stat
private func miniStat(_ label: String, value: Int) -> some View {
VStack(spacing: 2) {
Text("\(value)")
.font(.caption.weight(.bold).monospacedDigit())
.foregroundStyle(Color.textPrimary)
Text(label)
.font(.system(size: 9).weight(.medium))
.foregroundStyle(Color.textTertiary)
}
}
// MARK: - Bindings into vm.currentDraft
private func binding(_ keyPath: WritableKeyPath<FitnessDraft, String>) -> Binding<String> {
Binding(
get: { vm.currentDraft?[keyPath: keyPath] ?? "" },
set: { vm.currentDraft?[keyPath: keyPath] = $0 }
)
}
private func binding(_ keyPath: WritableKeyPath<FitnessDraft, Double>) -> Binding<Double> {
Binding(
get: { vm.currentDraft?[keyPath: keyPath] ?? 0 },
set: { vm.currentDraft?[keyPath: keyPath] = $0 }
)
}
private func doubleBinding(_ binding: Binding<Double>) -> Binding<String> {
Binding(
get: { String(Int(binding.wrappedValue)) },
set: { binding.wrappedValue = Double($0) ?? 0 }
)
}
private var quantityBinding: Binding<String> {
Binding(
get: { String(format: "%.1f", vm.currentDraft?.quantity ?? 1) },
set: { vm.currentDraft?.quantity = Double($0) ?? 1 }
)
}
}

View File

@@ -252,19 +252,19 @@ struct TemplateLogResponse: Decodable {
struct FitnessDraft: Identifiable {
let id = UUID()
let foodName: String
let mealType: String
let entryDate: String
let quantity: Double
let unit: String
let calories: Double
let protein: Double
let carbs: Double
let fat: Double
let sugar: Double
let fiber: Double
let note: String
let defaultServingLabel: String
var foodName: String
var mealType: String
var entryDate: String
var quantity: Double
var unit: String
var calories: Double
var protein: Double
var carbs: Double
var fat: Double
var sugar: Double
var fiber: Double
var note: String
var defaultServingLabel: String
init(from dict: [String: Any]) {
foodName = dict["food_name"] as? String ?? ""