feat: editable AI draft card — edit food/macros before adding (#16)
- 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:
@@ -13,6 +13,7 @@
|
|||||||
A10004 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10004 /* APIClient.swift */; };
|
A10004 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10004 /* APIClient.swift */; };
|
||||||
A10005 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10005 /* AuthManager.swift */; };
|
A10005 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10005 /* AuthManager.swift */; };
|
||||||
A10050 /* AppearanceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10050 /* AppearanceManager.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 */; };
|
A10006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10006 /* LoginView.swift */; };
|
||||||
A10007 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10007 /* HomeView.swift */; };
|
A10007 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10007 /* HomeView.swift */; };
|
||||||
A10008 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10008 /* HomeViewModel.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
B10008 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||||
@@ -242,6 +244,7 @@
|
|||||||
children = (
|
children = (
|
||||||
B10025 /* AssistantChatView.swift */,
|
B10025 /* AssistantChatView.swift */,
|
||||||
B10026 /* AssistantViewModel.swift */,
|
B10026 /* AssistantViewModel.swift */,
|
||||||
|
B10051 /* EditableDraftCard.swift */,
|
||||||
);
|
);
|
||||||
path = Assistant;
|
path = Assistant;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -415,6 +418,7 @@
|
|||||||
A10004 /* APIClient.swift in Sources */,
|
A10004 /* APIClient.swift in Sources */,
|
||||||
A10005 /* AuthManager.swift in Sources */,
|
A10005 /* AuthManager.swift in Sources */,
|
||||||
A10050 /* AppearanceManager.swift in Sources */,
|
A10050 /* AppearanceManager.swift in Sources */,
|
||||||
|
A10051 /* EditableDraftCard.swift in Sources */,
|
||||||
A10006 /* LoginView.swift in Sources */,
|
A10006 /* LoginView.swift in Sources */,
|
||||||
A10007 /* HomeView.swift in Sources */,
|
A10007 /* HomeView.swift in Sources */,
|
||||||
A10008 /* HomeViewModel.swift in Sources */,
|
A10008 /* HomeViewModel.swift in Sources */,
|
||||||
|
|||||||
@@ -151,53 +151,10 @@ struct AssistantChatView: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Draft Card
|
// MARK: - Draft Card (Editable)
|
||||||
|
|
||||||
private func draftCard(_ draft: FitnessDraft) -> some View {
|
private func draftCard(_ draft: FitnessDraft) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
EditableDraftCard(vm: vm)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var multipleDraftsCard: some View {
|
private var multipleDraftsCard: some View {
|
||||||
|
|||||||
202
ios/Platform/Platform/Features/Assistant/EditableDraftCard.swift
Normal file
202
ios/Platform/Platform/Features/Assistant/EditableDraftCard.swift
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -252,19 +252,19 @@ struct TemplateLogResponse: Decodable {
|
|||||||
|
|
||||||
struct FitnessDraft: Identifiable {
|
struct FitnessDraft: Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
let foodName: String
|
var foodName: String
|
||||||
let mealType: String
|
var mealType: String
|
||||||
let entryDate: String
|
var entryDate: String
|
||||||
let quantity: Double
|
var quantity: Double
|
||||||
let unit: String
|
var unit: String
|
||||||
let calories: Double
|
var calories: Double
|
||||||
let protein: Double
|
var protein: Double
|
||||||
let carbs: Double
|
var carbs: Double
|
||||||
let fat: Double
|
var fat: Double
|
||||||
let sugar: Double
|
var sugar: Double
|
||||||
let fiber: Double
|
var fiber: Double
|
||||||
let note: String
|
var note: String
|
||||||
let defaultServingLabel: String
|
var defaultServingLabel: String
|
||||||
|
|
||||||
init(from dict: [String: Any]) {
|
init(from dict: [String: Any]) {
|
||||||
foodName = dict["food_name"] as? String ?? ""
|
foodName = dict["food_name"] as? String ?? ""
|
||||||
|
|||||||
Reference in New Issue
Block a user