restore: original UI views from first build, keep fixed models/API
All checks were successful
Security Checks / dependency-audit (push) Successful in 12s
Security Checks / secret-scanning (push) Successful in 3s
Security Checks / dockerfile-lint (push) Successful in 3s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 01:54:46 -05:00
parent e852e98812
commit fdb8aeba8a
61 changed files with 6652 additions and 1178 deletions

View File

@@ -0,0 +1,239 @@
import SwiftUI
struct AddFoodSheet: View {
let food: FoodItem
@Binding var quantity: Double
@Binding var mealType: MealType
let isAdding: Bool
let onAdd: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var quantityText: String = "1"
var body: some View {
NavigationStack {
VStack(spacing: 24) {
// Food info header
foodHeader
// Quantity input
quantitySection
// Meal picker
mealPickerSection
// Macro preview
macroPreview
Spacer()
// Add button
Button(action: onAdd) {
HStack(spacing: 8) {
if isAdding {
ProgressView()
.controlSize(.small)
.tint(.white)
}
Text("Add to \(mealType.displayName)")
.font(.body.weight(.semibold))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Color.accentWarm)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(isAdding || quantity <= 0)
}
.padding(20)
.background(Color.canvas)
.navigationTitle("Add Food")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
.foregroundStyle(Color.text3)
}
}
.onAppear {
quantityText = formatQuantity(quantity)
}
}
}
private var foodHeader: some View {
HStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color.accentWarmBg)
.frame(width: 48, height: 48)
Image(systemName: "fork.knife")
.foregroundStyle(Color.accentWarm)
}
VStack(alignment: .leading, spacing: 2) {
Text(food.name)
.font(.headline)
.foregroundStyle(Color.text1)
.lineLimit(2)
Text("\(Int(food.caloriesPerBase)) kcal per \(food.displayUnit)")
.font(.caption)
.foregroundStyle(Color.text3)
}
Spacer()
}
}
private var quantitySection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Quantity (\(food.displayUnit))")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text3)
.textCase(.uppercase)
HStack(spacing: 12) {
// Decrement
Button {
adjustQuantity(by: -0.5)
} label: {
Image(systemName: "minus.circle.fill")
.font(.title2)
.foregroundStyle(quantity > 0.5 ? Color.accentWarm : Color.text4)
}
.disabled(quantity <= 0.5)
// Text field
TextField("1", text: $quantityText)
.textFieldStyle(.plain)
.keyboardType(.decimalPad)
.multilineTextAlignment(.center)
.font(.title2.weight(.bold))
.foregroundStyle(Color.text1)
.frame(width: 80)
.padding(.vertical, 8)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.black.opacity(0.06), lineWidth: 1)
)
.onChange(of: quantityText) {
if let val = Double(quantityText), val > 0 {
quantity = val
}
}
// Increment
Button {
adjustQuantity(by: 0.5)
} label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(Color.accentWarm)
}
Spacer()
// Quick presets
ForEach([0.5, 1.0, 2.0], id: \.self) { preset in
Button {
quantity = preset
quantityText = formatQuantity(preset)
} label: {
Text(formatQuantity(preset))
.font(.caption.weight(.semibold))
.foregroundStyle(quantity == preset ? .white : Color.text2)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(quantity == preset ? Color.accentWarm : Color.surfaceSecondary)
.clipShape(Capsule())
}
}
}
}
}
private var mealPickerSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Meal")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text3)
.textCase(.uppercase)
HStack(spacing: 8) {
ForEach(MealType.allCases) { meal in
Button {
mealType = meal
} label: {
VStack(spacing: 4) {
Image(systemName: meal.icon)
.font(.body)
Text(meal.displayName)
.font(.caption2.weight(.medium))
}
.foregroundStyle(mealType == meal ? .white : Color.text2)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(
mealType == meal
? Color.mealColor(for: meal.rawValue)
: Color.surfaceSecondary
)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
}
}
}
private var macroPreview: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Nutrition Preview")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text3)
.textCase(.uppercase)
HStack(spacing: 0) {
macroPreviewItem("Calories", value: food.scaledCalories(quantity: quantity), unit: "kcal", color: .caloriesColor)
Spacer()
macroPreviewItem("Protein", value: food.scaledProtein(quantity: quantity), unit: "g", color: .proteinColor)
Spacer()
macroPreviewItem("Carbs", value: food.scaledCarbs(quantity: quantity), unit: "g", color: .carbsColor)
Spacer()
macroPreviewItem("Fat", value: food.scaledFat(quantity: quantity), unit: "g", color: .fatColor)
}
.padding(16)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
private func macroPreviewItem(_ label: String, value: Double, unit: String, color: Color) -> some View {
VStack(spacing: 4) {
Text("\(Int(value))")
.font(.system(.title3, design: .rounded, weight: .bold))
.foregroundStyle(color)
Text(label)
.font(.caption2)
.foregroundStyle(Color.text3)
}
}
private func adjustQuantity(by amount: Double) {
quantity = max(0.5, quantity + amount)
quantityText = formatQuantity(quantity)
}
private func formatQuantity(_ qty: Double) -> String {
if qty == qty.rounded() {
return "\(Int(qty))"
}
return String(format: "%.1f", qty)
}
}

View File

@@ -0,0 +1,258 @@
import SwiftUI
struct EntryDetailView: View {
let entry: FoodEntry
let onDelete: () -> Void
let onUpdateQuantity: (Double) -> Void
@Environment(\.dismiss) private var dismiss
@State private var editQuantity: String
@State private var showDeleteConfirm = false
init(entry: FoodEntry, onDelete: @escaping () -> Void, onUpdateQuantity: @escaping (Double) -> Void) {
self.entry = entry
self.onDelete = onDelete
self.onUpdateQuantity = onUpdateQuantity
_editQuantity = State(initialValue: entry.quantity == entry.quantity.rounded() ? "\(Int(entry.quantity))" : String(format: "%.1f", entry.quantity))
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Header
entryHeader
// Quantity editor
quantityEditor
// Macros grid
macrosGrid
// Details
detailsSection
// Delete button
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
HStack(spacing: 8) {
Image(systemName: "trash")
Text("Delete Entry")
}
.font(.body.weight(.medium))
.foregroundStyle(Color.error)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color.error.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
.padding(20)
}
.background(Color.canvas)
.navigationTitle("Entry Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
dismiss()
}
.foregroundStyle(Color.accentWarm)
}
}
.confirmationDialog("Delete Entry", isPresented: $showDeleteConfirm) {
Button("Delete", role: .destructive) {
onDelete()
dismiss()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Are you sure you want to delete \"\(entry.foodName)\"?")
}
}
}
private var entryHeader: some View {
VStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.mealColor(for: entry.mealType).opacity(0.1))
.frame(width: 64, height: 64)
Image(systemName: Color.mealIcon(for: entry.mealType))
.font(.title2)
.foregroundStyle(Color.mealColor(for: entry.mealType))
}
Text(entry.foodName)
.font(.title3.weight(.semibold))
.foregroundStyle(Color.text1)
.multilineTextAlignment(.center)
Text(entry.mealType.capitalized)
.font(.caption.weight(.semibold))
.foregroundStyle(Color.mealColor(for: entry.mealType))
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(Color.mealColor(for: entry.mealType).opacity(0.1))
.clipShape(Capsule())
}
}
private var quantityEditor: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Quantity")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text3)
.textCase(.uppercase)
HStack(spacing: 12) {
Button {
let current = Double(editQuantity) ?? 1
let newVal = max(0.5, current - 0.5)
editQuantity = formatQuantity(newVal)
} label: {
Image(systemName: "minus.circle.fill")
.font(.title2)
.foregroundStyle(Color.accentWarm)
}
TextField("1", text: $editQuantity)
.textFieldStyle(.plain)
.keyboardType(.decimalPad)
.multilineTextAlignment(.center)
.font(.title2.weight(.bold))
.foregroundStyle(Color.text1)
.frame(width: 80)
.padding(.vertical, 8)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 10))
Button {
let current = Double(editQuantity) ?? 1
editQuantity = formatQuantity(current + 0.5)
} label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(Color.accentWarm)
}
Spacer()
Button("Save") {
if let qty = Double(editQuantity), qty > 0 {
onUpdateQuantity(qty)
dismiss()
}
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.accentWarm)
.clipShape(Capsule())
}
}
.padding(16)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
private var macrosGrid: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Nutrition")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text3)
.textCase(.uppercase)
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 12) {
macroCell("Calories", value: entry.calories, unit: "kcal", color: .caloriesColor)
macroCell("Protein", value: entry.protein, unit: "g", color: .proteinColor)
macroCell("Carbs", value: entry.carbs, unit: "g", color: .carbsColor)
macroCell("Fat", value: entry.fat, unit: "g", color: .fatColor)
if let sugar = entry.sugar {
macroCell("Sugar", value: sugar, unit: "g", color: .sugarColor)
}
if let fiber = entry.fiber {
macroCell("Fiber", value: fiber, unit: "g", color: .fiberColor)
}
}
}
.padding(16)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
private func macroCell(_ label: String, value: Double, unit: String, color: Color) -> some View {
VStack(spacing: 4) {
Text("\(Int(value))")
.font(.title3.weight(.bold))
.foregroundStyle(color)
Text(label)
.font(.caption2)
.foregroundStyle(Color.text3)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(color.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
private var detailsSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Details")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text3)
.textCase(.uppercase)
VStack(spacing: 0) {
detailRow("Serving", value: entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unit)")
if let method = entry.method, !method.isEmpty {
Divider()
detailRow("Method", value: method)
}
if let note = entry.note, !note.isEmpty {
Divider()
detailRow("Note", value: note)
}
if let loggedAt = entry.loggedAt, !loggedAt.isEmpty {
Divider()
detailRow("Logged", value: loggedAt)
}
}
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
private func detailRow(_ label: String, value: String) -> some View {
HStack {
Text(label)
.font(.subheadline)
.foregroundStyle(Color.text3)
Spacer()
Text(value)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.text1)
.lineLimit(2)
.multilineTextAlignment(.trailing)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private func formatQuantity(_ qty: Double) -> String {
if qty == qty.rounded() {
return "\(Int(qty))"
}
return String(format: "%.1f", qty)
}
}

View File

@@ -0,0 +1,75 @@
import SwiftUI
struct FitnessTabView: View {
@State private var selectedTab: FitnessTab = .today
@State private var todayVM = TodayViewModel()
@State private var showFoodSearch = false
enum FitnessTab: String, CaseIterable {
case today = "Today"
case history = "History"
case templates = "Templates"
case goals = "Goals"
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Custom segmented control
tabBar
// Content
Group {
switch selectedTab {
case .today:
TodayView(viewModel: todayVM, showFoodSearch: $showFoodSearch)
case .history:
HistoryView()
case .templates:
TemplatesView(dateString: todayVM.dateString) {
Task { await todayVM.load() }
}
case .goals:
GoalsView()
}
}
}
.background(Color.canvas)
.navigationTitle("Fitness")
.navigationBarTitleDisplayMode(.large)
.sheet(isPresented: $showFoodSearch) {
FoodSearchView(date: todayVM.dateString) {
Task { await todayVM.load() }
}
}
}
}
private var tabBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(FitnessTab.allCases, id: \.rawValue) { tab in
Button {
withAnimation(.easeInOut(duration: 0.2)) {
selectedTab = tab
}
} label: {
Text(tab.rawValue)
.font(.subheadline.weight(selectedTab == tab ? .semibold : .medium))
.foregroundStyle(selectedTab == tab ? Color.surface : Color.text3)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
selectedTab == tab
? Color.accentWarm
: Color.surfaceSecondary
)
.clipShape(Capsule())
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
}

View File

@@ -0,0 +1,198 @@
import SwiftUI
struct FoodSearchView: View {
let date: String
let onFoodAdded: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var viewModel = FoodSearchViewModel()
@FocusState private var searchFocused: Bool
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search bar
searchBar
// Content
if viewModel.isSearching || viewModel.isLoadingRecent {
LoadingView(message: viewModel.isSearching ? "Searching..." : "Loading recent...")
} else if viewModel.displayedFoods.isEmpty && !viewModel.isShowingRecent {
EmptyStateView(
icon: "magnifyingglass",
title: "No results",
subtitle: "Try a different search term"
)
} else {
foodList
}
}
.background(Color.canvas)
.navigationTitle("Add Food")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
.foregroundStyle(Color.text3)
}
}
.sheet(isPresented: $viewModel.showAddSheet) {
if let food = viewModel.selectedFood {
AddFoodSheet(
food: food,
quantity: $viewModel.addQuantity,
mealType: $viewModel.addMealType,
isAdding: viewModel.isAddingFood
) {
Task {
await viewModel.addFood(date: date) {
onFoodAdded()
dismiss()
}
}
}
.presentationDetents([.medium])
}
}
.task {
await viewModel.loadRecent()
searchFocused = true
}
}
}
private var searchBar: some View {
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.foregroundStyle(Color.text4)
TextField("Search foods...", text: $viewModel.searchText)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($searchFocused)
.onSubmit {
viewModel.search()
}
.onChange(of: viewModel.searchText) {
viewModel.search()
}
if !viewModel.searchText.isEmpty {
Button {
viewModel.searchText = ""
viewModel.searchResults = []
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Color.text4)
}
}
}
.padding(12)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
private var foodList: some View {
ScrollView {
LazyVStack(spacing: 0) {
if viewModel.isShowingRecent && !viewModel.recentFoods.isEmpty {
sectionHeader("Recent Foods")
}
ForEach(viewModel.displayedFoods) { food in
FoodItemRow(food: food) {
viewModel.selectFood(food)
}
Divider()
.padding(.leading, 60)
}
}
}
}
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text4)
.textCase(.uppercase)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 8)
}
}
struct FoodItemRow: View {
let food: FoodItem
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
// Icon
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color.accentWarmBg)
.frame(width: 40, height: 40)
if let imageUrl = food.imageUrl, !imageUrl.isEmpty {
AsyncImage(url: URL(string: imageUrl)) { image in
image
.resizable()
.scaledToFill()
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 10))
} placeholder: {
Image(systemName: "fork.knife")
.font(.caption)
.foregroundStyle(Color.accentWarm)
}
} else {
Image(systemName: "fork.knife")
.font(.caption)
.foregroundStyle(Color.accentWarm)
}
}
// Info
VStack(alignment: .leading, spacing: 2) {
Text(food.name)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.text1)
.lineLimit(1)
Text(food.displayInfo)
.font(.caption)
.foregroundStyle(Color.text3)
.lineLimit(1)
}
Spacer()
// Calories
VStack(alignment: .trailing, spacing: 2) {
Text("\(Int(food.caloriesPerBase))")
.font(.subheadline.weight(.bold))
.foregroundStyle(Color.text1)
Text("kcal")
.font(.caption2)
.foregroundStyle(Color.text4)
}
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(Color.text4)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,139 @@
import SwiftUI
struct GoalsView: View {
@State private var viewModel = GoalsViewModel()
var body: some View {
ScrollView {
if viewModel.isLoading {
LoadingView(message: "Loading goals...")
.frame(height: 300)
} else {
VStack(spacing: 20) {
// Header
Text("Your daily targets")
.font(.headline)
.foregroundStyle(Color.text1)
.frame(maxWidth: .infinity, alignment: .leading)
// Goals cards
goalCard(
label: "Calories",
value: viewModel.goal.calories,
unit: "kcal",
icon: "flame.fill",
color: .caloriesColor
)
goalCard(
label: "Protein",
value: viewModel.goal.protein,
unit: "g",
icon: "circle.hexagonpath.fill",
color: .proteinColor
)
goalCard(
label: "Carbs",
value: viewModel.goal.carbs,
unit: "g",
icon: "bolt.fill",
color: .carbsColor
)
goalCard(
label: "Fat",
value: viewModel.goal.fat,
unit: "g",
icon: "drop.fill",
color: .fatColor
)
if let sugar = viewModel.goal.sugar, sugar > 0 {
goalCard(
label: "Sugar",
value: sugar,
unit: "g",
icon: "cube.fill",
color: .sugarColor
)
}
if let fiber = viewModel.goal.fiber, fiber > 0 {
goalCard(
label: "Fiber",
value: fiber,
unit: "g",
icon: "leaf.fill",
color: .fiberColor
)
}
// Info note
HStack(spacing: 8) {
Image(systemName: "info.circle")
.foregroundStyle(Color.text4)
Text("Goals can be updated from the web app")
.font(.caption)
.foregroundStyle(Color.text3)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 8)
}
.padding(16)
}
if let error = viewModel.errorMessage {
ErrorBanner(message: error) {
Task { await viewModel.load() }
}
.padding(16)
}
}
.refreshable {
await viewModel.load()
}
.task {
await viewModel.load()
}
}
private func goalCard(label: String, value: Double, unit: String, icon: String, color: Color) -> some View {
HStack(spacing: 16) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(color.opacity(0.1))
.frame(width: 48, height: 48)
Image(systemName: icon)
.font(.title3)
.foregroundStyle(color)
}
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.text2)
Text("Daily target")
.font(.caption)
.foregroundStyle(Color.text4)
}
Spacer()
HStack(alignment: .firstTextBaseline, spacing: 2) {
Text("\(Int(value))")
.font(.title2.weight(.bold))
.foregroundStyle(Color.text1)
Text(unit)
.font(.subheadline)
.foregroundStyle(Color.text3)
}
}
.padding(16)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
}
}

View File

@@ -0,0 +1,159 @@
import SwiftUI
struct HistoryView: View {
@State private var viewModel = HistoryViewModel()
var body: some View {
ScrollView {
if viewModel.isLoading {
LoadingView(message: "Loading history...")
.frame(height: 300)
} else if viewModel.days.isEmpty {
EmptyStateView(
icon: "calendar",
title: "No history",
subtitle: "Start logging food to see your history"
)
} else {
LazyVStack(spacing: 12) {
ForEach(viewModel.days) { day in
HistoryDayCard(day: day)
}
}
.padding(16)
}
if let error = viewModel.errorMessage {
ErrorBanner(message: error) {
Task { await viewModel.load() }
}
.padding(16)
}
}
.refreshable {
await viewModel.load()
}
.task {
await viewModel.load()
}
}
}
struct HistoryDayCard: View {
let day: HistoryViewModel.HistoryDay
@State private var isExpanded = false
var body: some View {
VStack(spacing: 0) {
// Header
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isExpanded.toggle()
}
} label: {
HStack(spacing: 12) {
// Date
VStack(alignment: .leading, spacing: 2) {
Text(day.date.relativeLabel)
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.text1)
Text(day.date.shortDisplayString)
.font(.caption)
.foregroundStyle(Color.text3)
}
Spacer()
// Quick stats
HStack(spacing: 16) {
VStack(spacing: 2) {
Text("\(Int(day.totalCalories))")
.font(.subheadline.weight(.bold))
.foregroundStyle(Color.text1)
Text("kcal")
.font(.caption2)
.foregroundStyle(Color.text4)
}
// Mini progress ring
ZStack {
Circle()
.stroke(Color.caloriesColor.opacity(0.12), lineWidth: 3)
Circle()
.trim(from: 0, to: day.calorieProgress)
.stroke(Color.caloriesColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
}
.frame(width: 28, height: 28)
}
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text4)
}
.padding(16)
}
.buttonStyle(.plain)
if isExpanded {
Divider()
.padding(.horizontal, 16)
// Macros row
HStack(spacing: 0) {
historyMacro("Protein", value: day.totalProtein, color: .proteinColor)
Spacer()
historyMacro("Carbs", value: day.totalCarbs, color: .carbsColor)
Spacer()
historyMacro("Fat", value: day.totalFat, color: .fatColor)
Spacer()
historyMacro("Entries", value: Double(day.entryCount), color: .text3, isCount: true)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
if !day.entries.isEmpty {
Divider()
.padding(.horizontal, 16)
// Entries list
ForEach(day.entries) { entry in
HStack(spacing: 8) {
Circle()
.fill(Color.mealColor(for: entry.mealType))
.frame(width: 6, height: 6)
Text(entry.foodName)
.font(.caption)
.foregroundStyle(Color.text2)
.lineLimit(1)
Spacer()
Text("\(Int(entry.calories)) kcal")
.font(.caption)
.foregroundStyle(Color.text3)
}
.padding(.horizontal, 20)
.padding(.vertical, 4)
}
.padding(.vertical, 4)
}
}
}
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
}
private func historyMacro(_ label: String, value: Double, color: Color, isCount: Bool = false) -> some View {
VStack(spacing: 2) {
Text("\(Int(value))\(isCount ? "" : "g")")
.font(.subheadline.weight(.semibold))
.foregroundStyle(color)
Text(label)
.font(.caption2)
.foregroundStyle(Color.text4)
}
}
}

View File

@@ -0,0 +1,261 @@
import SwiftUI
struct MealSectionView: View {
let group: MealGroup
let isExpanded: Bool
let onToggle: () -> Void
let onDelete: (FoodEntry) -> Void
let onAddFood: () -> Void
@State private var selectedEntry: FoodEntry?
private var mealColor: Color {
Color.mealColor(for: group.meal.rawValue)
}
var body: some View {
VStack(spacing: 0) {
// Header
Button(action: onToggle) {
HStack(spacing: 10) {
Image(systemName: group.meal.icon)
.font(.body)
.foregroundStyle(mealColor)
.frame(width: 28)
Text(group.meal.displayName)
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.text1)
if !group.entries.isEmpty {
Text("\(group.entries.count)")
.font(.caption2.weight(.bold))
.foregroundStyle(mealColor)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(mealColor.opacity(0.1))
.clipShape(Capsule())
}
Spacer()
if !group.entries.isEmpty {
Text("\(Int(group.totalCalories)) kcal")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text3)
}
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text4)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.buttonStyle(.plain)
// Entries
if isExpanded {
if group.entries.isEmpty {
emptyMealView
} else {
Divider()
.padding(.horizontal, 16)
ForEach(group.entries) { entry in
SwipeToDeleteRow(onDelete: { onDelete(entry) }) {
EntryRow(entry: entry)
.contentShape(Rectangle())
.onTapGesture {
selectedEntry = entry
}
}
if entry.id != group.entries.last?.id {
Divider()
.padding(.leading, 52)
}
}
}
}
}
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
.sheet(item: $selectedEntry) { entry in
EntryDetailView(
entry: entry,
onDelete: { onDelete(entry) },
onUpdateQuantity: { _ in }
)
.presentationDetents([.large])
}
}
private var emptyMealView: some View {
Button(action: onAddFood) {
HStack(spacing: 8) {
Image(systemName: "plus.circle")
.foregroundStyle(mealColor)
Text("Add food")
.font(.subheadline)
.foregroundStyle(Color.text3)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
.buttonStyle(.plain)
}
}
// MARK: - Swipe to Delete Row
struct SwipeToDeleteRow<Content: View>: View {
let onDelete: () -> Void
@ViewBuilder let content: () -> Content
@State private var offset: CGFloat = 0
@State private var showDelete = false
private let deleteThreshold: CGFloat = -80
private let deleteWidth: CGFloat = 80
var body: some View {
ZStack(alignment: .trailing) {
// Delete background
HStack {
Spacer()
Button(action: {
withAnimation(.easeOut(duration: 0.2)) {
offset = -300
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
onDelete()
}
}) {
Image(systemName: "trash.fill")
.foregroundStyle(.white)
.frame(width: deleteWidth, height: .infinity)
}
.frame(width: deleteWidth)
.background(Color.error)
}
.opacity(offset < 0 ? 1 : 0)
// Content
content()
.offset(x: offset)
.gesture(
DragGesture(minimumDistance: 20)
.onChanged { value in
let translation = value.translation.width
if translation < 0 {
offset = translation
}
}
.onEnded { value in
withAnimation(.easeOut(duration: 0.2)) {
if offset < deleteThreshold {
offset = -deleteWidth
showDelete = true
} else {
offset = 0
showDelete = false
}
}
}
)
.onTapGesture {
if showDelete {
withAnimation(.easeOut(duration: 0.2)) {
offset = 0
showDelete = false
}
}
}
}
.clipped()
}
}
// MARK: - Entry Row
struct EntryRow: View {
let entry: FoodEntry
var body: some View {
HStack(spacing: 12) {
// Food icon or image
ZStack {
Circle()
.fill(Color.mealColor(for: entry.mealType).opacity(0.1))
.frame(width: 36, height: 36)
if let imageUrl = entry.imageUrl, !imageUrl.isEmpty {
AsyncImage(url: URL(string: imageUrl)) { image in
image
.resizable()
.scaledToFill()
.frame(width: 36, height: 36)
.clipShape(Circle())
} placeholder: {
Image(systemName: "fork.knife")
.font(.caption)
.foregroundStyle(Color.mealColor(for: entry.mealType))
}
} else {
Image(systemName: "fork.knife")
.font(.caption)
.foregroundStyle(Color.mealColor(for: entry.mealType))
}
}
// Name and serving
VStack(alignment: .leading, spacing: 2) {
Text(entry.foodName)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.text1)
.lineLimit(1)
Text(entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unit)")
.font(.caption)
.foregroundStyle(Color.text3)
.lineLimit(1)
}
Spacer()
// Macros
VStack(alignment: .trailing, spacing: 2) {
Text("\(Int(entry.calories))")
.font(.subheadline.weight(.bold))
.foregroundStyle(Color.text1)
+ Text(" kcal")
.font(.caption2)
.foregroundStyle(Color.text3)
HStack(spacing: 6) {
macroTag("P", value: entry.protein, color: .proteinColor)
macroTag("C", value: entry.carbs, color: .carbsColor)
macroTag("F", value: entry.fat, color: .fatColor)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color.surface)
}
private func macroTag(_ label: String, value: Double, color: Color) -> some View {
Text("\(label)\(Int(value))")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(color)
}
private func formatQuantity(_ qty: Double) -> String {
if qty == qty.rounded() {
return "\(Int(qty))"
}
return String(format: "%.1f", qty)
}
}

View File

@@ -0,0 +1,170 @@
import SwiftUI
struct TemplatesView: View {
let dateString: String
let onTemplateLogged: () -> Void
@State private var viewModel = TemplatesViewModel()
@State private var confirmTemplate: MealTemplate?
var body: some View {
ScrollView {
if viewModel.isLoading {
LoadingView(message: "Loading templates...")
.frame(height: 300)
} else if viewModel.templates.isEmpty {
EmptyStateView(
icon: "doc.text",
title: "No templates",
subtitle: "Create templates on the web app to quickly log meals"
)
} else {
LazyVStack(spacing: 16) {
ForEach(MealType.allCases) { meal in
let templates = viewModel.groupedTemplates[meal.rawValue] ?? []
if !templates.isEmpty {
templateSection(meal: meal, templates: templates)
}
}
// Ungrouped
let ungrouped = viewModel.templates.filter { template in
!MealType.allCases.map(\.rawValue).contains(template.mealType)
}
if !ungrouped.isEmpty {
templateSection(mealLabel: "Other", icon: "ellipsis.circle.fill", color: .text3, templates: ungrouped)
}
}
.padding(16)
}
if let error = viewModel.errorMessage {
ErrorBanner(message: error) {
Task { await viewModel.load() }
}
.padding(16)
}
}
.refreshable {
await viewModel.load()
}
.task {
await viewModel.load()
}
.confirmationDialog(
"Log Template",
isPresented: Binding(
get: { confirmTemplate != nil },
set: { if !$0 { confirmTemplate = nil } }
),
presenting: confirmTemplate
) { template in
Button("Log \"\(template.name)\"") {
Task {
await viewModel.logTemplate(template, date: dateString) {
onTemplateLogged()
}
}
}
Button("Cancel", role: .cancel) {}
} message: { template in
Text("This will add all items from \"\(template.name)\" (\(Int(template.calories)) kcal) to \(dateString).")
}
}
private func templateSection(meal: MealType, templates: [MealTemplate]) -> some View {
templateSection(
mealLabel: meal.displayName,
icon: meal.icon,
color: Color.mealColor(for: meal.rawValue),
templates: templates
)
}
private func templateSection(mealLabel: String, icon: String, color: Color, templates: [MealTemplate]) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: icon)
.foregroundStyle(color)
Text(mealLabel)
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.text1)
}
ForEach(templates) { template in
TemplateCard(
template: template,
isLogging: viewModel.loggedTemplateId == template.id
) {
confirmTemplate = template
}
}
}
}
}
struct TemplateCard: View {
let template: MealTemplate
let isLogging: Bool
let onLog: () -> Void
var body: some View {
HStack(spacing: 14) {
// Icon
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color.mealColor(for: template.mealType).opacity(0.1))
.frame(width: 44, height: 44)
Image(systemName: "doc.text.fill")
.foregroundStyle(Color.mealColor(for: template.mealType))
}
// Info
VStack(alignment: .leading, spacing: 4) {
Text(template.name)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.text1)
.lineLimit(1)
HStack(spacing: 8) {
Text("\(Int(template.calories)) kcal")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.caloriesColor)
if let count = template.itemsCount {
Text("\(count) items")
.font(.caption)
.foregroundStyle(Color.text4)
}
if let protein = template.protein {
Text("P\(Int(protein))")
.font(.caption)
.foregroundStyle(Color.proteinColor)
}
}
}
Spacer()
// Log button
Button(action: onLog) {
if isLogging {
ProgressView()
.controlSize(.small)
.tint(Color.accentWarm)
} else {
Image(systemName: "plus.circle.fill")
.font(.title3)
.foregroundStyle(Color.accentWarm)
}
}
.disabled(isLogging)
}
.padding(14)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
}
}

View File

@@ -0,0 +1,171 @@
import SwiftUI
struct TodayView: View {
@Bindable var viewModel: TodayViewModel
@Binding var showFoodSearch: Bool
var body: some View {
ZStack(alignment: .bottomTrailing) {
ScrollView {
VStack(spacing: 16) {
// Date selector
dateSelector
if viewModel.isLoading {
LoadingView(message: "Loading entries...")
.frame(height: 200)
} else {
// Macro summary card
macroSummaryCard
// Meal sections
ForEach(viewModel.mealGroups) { group in
MealSectionView(
group: group,
isExpanded: viewModel.expandedMeals.contains(group.meal.rawValue),
onToggle: { viewModel.toggleMeal(group.meal.rawValue) },
onDelete: { entry in
Task { await viewModel.deleteEntry(entry) }
},
onAddFood: {
showFoodSearch = true
}
)
}
// Bottom spacing for FAB
Spacer()
.frame(height: 80)
}
if let error = viewModel.errorMessage {
ErrorBanner(message: error) {
Task { await viewModel.load() }
}
.padding(.horizontal, 4)
}
}
.padding(16)
}
.refreshable {
await viewModel.load()
}
// Floating add button
addButton
}
.task {
await viewModel.load()
}
}
// MARK: - Date Selector
private var dateSelector: some View {
HStack(spacing: 0) {
Button {
viewModel.goToPreviousDay()
} label: {
Image(systemName: "chevron.left")
.font(.body.weight(.semibold))
.foregroundStyle(Color.accentWarm)
.frame(width: 44, height: 44)
}
Spacer()
VStack(spacing: 2) {
Text(viewModel.selectedDate.relativeLabel)
.font(.headline)
.foregroundStyle(Color.text1)
if !viewModel.selectedDate.isToday {
Text(viewModel.selectedDate.displayString)
.font(.caption)
.foregroundStyle(Color.text3)
}
}
.onTapGesture {
viewModel.goToToday()
}
Spacer()
Button {
viewModel.goToNextDay()
} label: {
Image(systemName: "chevron.right")
.font(.body.weight(.semibold))
.foregroundStyle(
viewModel.selectedDate.isToday ? Color.text4 : Color.accentWarm
)
.frame(width: 44, height: 44)
}
.disabled(viewModel.selectedDate.isToday)
}
.padding(.horizontal, 4)
.padding(.vertical, 4)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
}
// MARK: - Macro Summary
private var macroSummaryCard: some View {
VStack(spacing: 16) {
// Calories ring
HStack(spacing: 20) {
MacroRingLarge(
current: viewModel.totalCalories,
goal: viewModel.goal.calories,
color: .caloriesColor,
size: 100,
lineWidth: 9
)
VStack(alignment: .leading, spacing: 10) {
macroRow("Protein", current: viewModel.totalProtein, goal: viewModel.goal.protein, color: .proteinColor)
macroRow("Carbs", current: viewModel.totalCarbs, goal: viewModel.goal.carbs, color: .carbsColor)
macroRow("Fat", current: viewModel.totalFat, goal: viewModel.goal.fat, color: .fatColor)
}
.frame(maxWidth: .infinity)
}
}
.padding(20)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
}
private func macroRow(_ label: String, current: Double, goal: Double, color: Color) -> some View {
VStack(spacing: 4) {
HStack {
Text(label)
.font(.caption.weight(.medium))
.foregroundStyle(Color.text3)
Spacer()
Text("\(Int(current))/\(Int(goal))g")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text2)
}
MacroBarCompact(current: current, goal: goal, color: color)
}
}
// MARK: - Add Button
private var addButton: some View {
Button {
showFoodSearch = true
} label: {
Image(systemName: "plus")
.font(.title2.weight(.semibold))
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(Color.accentWarm)
.clipShape(Circle())
.shadow(color: Color.accentWarm.opacity(0.3), radius: 8, y: 4)
}
.padding(20)
}
}