restore: original UI views from first build, keep fixed models/API
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
enum Config {
|
||||
static let gatewayURL = "https://dash.quadjourney.com"
|
||||
static let gatewayURL: URL = {
|
||||
if let override = UserDefaults.standard.string(forKey: "gateway_url"),
|
||||
let url = URL(string: override) {
|
||||
return url
|
||||
}
|
||||
return URL(string: "https://dash.quadjourney.com")!
|
||||
}()
|
||||
|
||||
static func apiURL(_ path: String) -> URL {
|
||||
gatewayURL.appendingPathComponent(path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,8 @@ struct ContentView: View {
|
||||
var body: some View {
|
||||
Group {
|
||||
if authManager.isCheckingAuth {
|
||||
ProgressView("Loading...")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.canvas)
|
||||
} else if authManager.isLoggedIn {
|
||||
LoadingView(message: "Checking session...")
|
||||
} else if authManager.isAuthenticated {
|
||||
MainTabView()
|
||||
} else {
|
||||
LoginView()
|
||||
@@ -22,22 +20,18 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
struct MainTabView: View {
|
||||
@State private var selectedTab = 0
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
TabView {
|
||||
HomeView()
|
||||
.tabItem {
|
||||
Label("Home", systemImage: "house.fill")
|
||||
}
|
||||
.tag(0)
|
||||
|
||||
FitnessTabView()
|
||||
.tabItem {
|
||||
Label("Fitness", systemImage: "figure.run")
|
||||
Label("Fitness", systemImage: "flame.fill")
|
||||
}
|
||||
.tag(1)
|
||||
}
|
||||
.tint(.accentWarm)
|
||||
.tint(Color.accentWarm)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,79 +2,157 @@ import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@Environment(AuthManager.self) private var authManager
|
||||
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var isLoading = false
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
private enum Field: Hashable {
|
||||
case username, password
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Color.canvas
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "square.grid.2x2.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
Text("Platform")
|
||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text("Sign in to continue")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
ScrollView {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
.frame(height: 60)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
TextField("Username", text: $username)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(14)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
// Logo / Branding
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "square.grid.2x2.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
|
||||
SecureField("Password", text: $password)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(14)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.textContentType(.password)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
Text("Platform")
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.foregroundStyle(Color.text1)
|
||||
|
||||
if let error = authManager.loginError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
Button {
|
||||
isLoading = true
|
||||
Task {
|
||||
await authManager.login(username: username, password: password)
|
||||
isLoading = false
|
||||
}
|
||||
} label: {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text("Sign In")
|
||||
.fontWeight(.semibold)
|
||||
Text("Sign in to your dashboard")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.text3)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.accentWarm)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 32)
|
||||
.disabled(username.isEmpty || password.isEmpty || isLoading)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
// Form
|
||||
VStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Username")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Color.text3)
|
||||
.textCase(.uppercase)
|
||||
|
||||
TextField("Enter username", text: $username)
|
||||
.textFieldStyle(.plain)
|
||||
.textContentType(.username)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.focused($focusedField, equals: .username)
|
||||
.padding(14)
|
||||
.background(Color.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(
|
||||
focusedField == .username ? Color.accentWarm : Color.black.opacity(0.06),
|
||||
lineWidth: focusedField == .username ? 2 : 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Password")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Color.text3)
|
||||
.textCase(.uppercase)
|
||||
|
||||
SecureField("Enter password", text: $password)
|
||||
.textFieldStyle(.plain)
|
||||
.textContentType(.password)
|
||||
.focused($focusedField, equals: .password)
|
||||
.padding(14)
|
||||
.background(Color.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(
|
||||
focusedField == .password ? Color.accentWarm : Color.black.opacity(0.06),
|
||||
lineWidth: focusedField == .password ? 2 : 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if let error = authManager.loginError {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundStyle(Color.error)
|
||||
Text(error)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.error)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
// Sign In Button
|
||||
Button {
|
||||
performLogin()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(.white)
|
||||
}
|
||||
Text("Sign In")
|
||||
.font(.body.weight(.semibold))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(canSubmit ? Color.accentWarm : Color.text4)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.disabled(!canSubmit)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
}
|
||||
}
|
||||
.onSubmit {
|
||||
switch focusedField {
|
||||
case .username:
|
||||
focusedField = .password
|
||||
case .password:
|
||||
performLogin()
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
!username.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
&& !password.isEmpty
|
||||
&& !isLoading
|
||||
}
|
||||
|
||||
private func performLogin() {
|
||||
guard canSubmit else { return }
|
||||
isLoading = true
|
||||
focusedField = nil
|
||||
Task {
|
||||
await authManager.login(
|
||||
username: username.trimmingCharacters(in: .whitespaces),
|
||||
password: password
|
||||
)
|
||||
isLoading = false
|
||||
}
|
||||
.background(Color.canvas.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,108 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
@MainActor @Observable
|
||||
final class FoodSearchViewModel {
|
||||
var query = ""
|
||||
var results: [FoodItem] = []
|
||||
var searchText = ""
|
||||
var searchResults: [FoodItem] = []
|
||||
var recentFoods: [FoodItem] = []
|
||||
var allFoods: [FoodItem] = []
|
||||
var isSearching = false
|
||||
var isLoadingInitial = false
|
||||
var isLoadingRecent = false
|
||||
var errorMessage: String?
|
||||
|
||||
private let api = FitnessAPI()
|
||||
// Add food sheet state
|
||||
var selectedFood: FoodItem?
|
||||
var showAddSheet = false
|
||||
var addQuantity: Double = 1
|
||||
var addMealType: MealType = .guess()
|
||||
var isAddingFood = false
|
||||
|
||||
private let repo = FitnessRepository.shared
|
||||
private var searchTask: Task<Void, Never>?
|
||||
|
||||
func loadInitial() async {
|
||||
isLoadingInitial = true
|
||||
do {
|
||||
async let r = api.getRecentFoods()
|
||||
async let a = api.getFoods()
|
||||
recentFoods = try await r
|
||||
allFoods = try await a
|
||||
} catch {
|
||||
// Silently fail
|
||||
var displayedFoods: [FoodItem] {
|
||||
if searchText.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
return recentFoods
|
||||
}
|
||||
isLoadingInitial = false
|
||||
return searchResults
|
||||
}
|
||||
|
||||
var isShowingRecent: Bool {
|
||||
searchText.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
|
||||
func loadRecent() async {
|
||||
isLoadingRecent = true
|
||||
do {
|
||||
recentFoods = try await repo.recentFoods(forceRefresh: true)
|
||||
} catch {
|
||||
// Silent failure for recent foods
|
||||
}
|
||||
isLoadingRecent = false
|
||||
}
|
||||
|
||||
func search() {
|
||||
let query = searchText.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
// Cancel previous search
|
||||
searchTask?.cancel()
|
||||
let q = query.trimmingCharacters(in: .whitespaces)
|
||||
guard q.count >= 2 else {
|
||||
results = []
|
||||
|
||||
guard !query.isEmpty else {
|
||||
searchResults = []
|
||||
isSearching = false
|
||||
return
|
||||
}
|
||||
|
||||
guard query.count >= 2 else {
|
||||
return
|
||||
}
|
||||
|
||||
isSearching = true
|
||||
searchTask = Task {
|
||||
// Debounce
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
do {
|
||||
let items = try await api.searchFoods(query: q)
|
||||
if !Task.isCancelled {
|
||||
results = items
|
||||
isSearching = false
|
||||
}
|
||||
let results = try await repo.searchFoods(query: query)
|
||||
guard !Task.isCancelled else { return }
|
||||
searchResults = results
|
||||
} catch {
|
||||
if !Task.isCancelled {
|
||||
isSearching = false
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
isSearching = false
|
||||
}
|
||||
}
|
||||
|
||||
func selectFood(_ food: FoodItem) {
|
||||
selectedFood = food
|
||||
addQuantity = 1
|
||||
addMealType = .guess()
|
||||
showAddSheet = true
|
||||
}
|
||||
|
||||
func addFood(date: String, onComplete: @escaping () -> Void) async {
|
||||
guard let food = selectedFood else { return }
|
||||
isAddingFood = true
|
||||
|
||||
let request = CreateEntryRequest(
|
||||
foodId: food.id,
|
||||
quantity: addQuantity,
|
||||
unit: food.baseUnit ?? "serving",
|
||||
mealType: addMealType.rawValue,
|
||||
entryDate: date,
|
||||
entryMethod: "manual",
|
||||
source: "ios_app"
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try await repo.createEntry(request)
|
||||
showAddSheet = false
|
||||
selectedFood = nil
|
||||
onComplete()
|
||||
} catch {
|
||||
errorMessage = "Failed to add food: \(error.localizedDescription)"
|
||||
}
|
||||
isAddingFood = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
@MainActor @Observable
|
||||
final class GoalsViewModel {
|
||||
var calories: String = ""
|
||||
var protein: String = ""
|
||||
var carbs: String = ""
|
||||
var fat: String = ""
|
||||
var sugar: String = ""
|
||||
var fiber: String = ""
|
||||
var isLoading = false
|
||||
var isSaving = false
|
||||
var error: String?
|
||||
var saved = false
|
||||
var goal: DailyGoal = .defaultGoal
|
||||
var isLoading = true
|
||||
var errorMessage: String?
|
||||
|
||||
private let api = FitnessAPI()
|
||||
private let repo = FitnessRepository.shared
|
||||
|
||||
func load(date: String) async {
|
||||
func load() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let goals = try await api.getGoals(date: date)
|
||||
calories = String(Int(goals.calories))
|
||||
protein = String(Int(goals.protein))
|
||||
carbs = String(Int(goals.carbs))
|
||||
fat = String(Int(goals.fat))
|
||||
sugar = String(Int(goals.sugar))
|
||||
fiber = String(Int(goals.fiber))
|
||||
goal = try await repo.goals(for: Date().apiDateString, forceRefresh: true)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func save() async {
|
||||
isSaving = true
|
||||
error = nil
|
||||
saved = false
|
||||
let req = UpdateGoalsRequest(
|
||||
calories: Double(calories) ?? 2000,
|
||||
protein: Double(protein) ?? 150,
|
||||
carbs: Double(carbs) ?? 250,
|
||||
fat: Double(fat) ?? 65,
|
||||
sugar: Double(sugar) ?? 50,
|
||||
fiber: Double(fiber) ?? 30
|
||||
)
|
||||
do {
|
||||
_ = try await api.updateGoals(req)
|
||||
saved = true
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor @Observable
|
||||
final class HistoryViewModel {
|
||||
var days: [HistoryDay] = []
|
||||
var isLoading = true
|
||||
var errorMessage: String?
|
||||
|
||||
private let repo = FitnessRepository.shared
|
||||
private let numberOfDays = 14
|
||||
|
||||
struct HistoryDay: Identifiable {
|
||||
let date: Date
|
||||
let dateString: String
|
||||
let entries: [FoodEntry]
|
||||
let goal: DailyGoal
|
||||
|
||||
var id: String { dateString }
|
||||
|
||||
var totalCalories: Double {
|
||||
entries.reduce(0) { $0 + $1.calories }
|
||||
}
|
||||
|
||||
var totalProtein: Double {
|
||||
entries.reduce(0) { $0 + $1.protein }
|
||||
}
|
||||
|
||||
var totalCarbs: Double {
|
||||
entries.reduce(0) { $0 + $1.carbs }
|
||||
}
|
||||
|
||||
var totalFat: Double {
|
||||
entries.reduce(0) { $0 + $1.fat }
|
||||
}
|
||||
|
||||
var entryCount: Int {
|
||||
entries.count
|
||||
}
|
||||
|
||||
var calorieProgress: Double {
|
||||
guard goal.calories > 0 else { return 0 }
|
||||
return min(totalCalories / goal.calories, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
var results: [HistoryDay] = []
|
||||
|
||||
do {
|
||||
// Load past N days
|
||||
for i in 0..<numberOfDays {
|
||||
let date = Date().adding(days: -i)
|
||||
let dateString = date.apiDateString
|
||||
|
||||
let entries = try await repo.entries(for: dateString, forceRefresh: i == 0)
|
||||
let goal = try await repo.goals(for: dateString)
|
||||
|
||||
results.append(HistoryDay(
|
||||
date: date,
|
||||
dateString: dateString,
|
||||
entries: entries,
|
||||
goal: goal
|
||||
))
|
||||
}
|
||||
days = results
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
days = results // Show what we have
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
@MainActor @Observable
|
||||
final class TemplatesViewModel {
|
||||
var templates: [MealTemplate] = []
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
var logSuccess: String?
|
||||
var isLoading = true
|
||||
var errorMessage: String?
|
||||
var isLogging = false
|
||||
var loggedTemplateId: String?
|
||||
|
||||
private let api = FitnessAPI()
|
||||
private let repo = FitnessRepository.shared
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
templates = try await api.getTemplates()
|
||||
templates = try await repo.templates(forceRefresh: true)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func logTemplate(_ template: MealTemplate, date: String) async {
|
||||
func logTemplate(_ template: MealTemplate, date: String, onComplete: @escaping () -> Void) async {
|
||||
isLogging = true
|
||||
loggedTemplateId = template.id
|
||||
|
||||
do {
|
||||
try await api.logTemplate(id: template.id, date: date)
|
||||
logSuccess = "Logged \(template.name)"
|
||||
// Refresh the repository
|
||||
await FitnessRepository.shared.loadDay(date: date)
|
||||
try await repo.logTemplate(id: template.id, date: date)
|
||||
loggedTemplateId = nil
|
||||
onComplete()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
errorMessage = "Failed to log template: \(error.localizedDescription)"
|
||||
loggedTemplateId = nil
|
||||
}
|
||||
|
||||
isLogging = false
|
||||
}
|
||||
|
||||
var groupedByMeal: [MealType: [MealTemplate]] {
|
||||
Dictionary(grouping: templates, by: { $0.mealType })
|
||||
var groupedTemplates: [String: [MealTemplate]] {
|
||||
Dictionary(grouping: templates, by: \.mealType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,111 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
@MainActor @Observable
|
||||
final class TodayViewModel {
|
||||
var entries: [FoodEntry] = []
|
||||
var goal: DailyGoal = .defaultGoal
|
||||
var selectedDate: Date = Date()
|
||||
let repository = FitnessRepository.shared
|
||||
var isLoading = true
|
||||
var errorMessage: String?
|
||||
var expandedMeals: Set<String> = Set(MealType.allCases.map(\.rawValue))
|
||||
|
||||
private let repo = FitnessRepository.shared
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var dateString: String {
|
||||
selectedDate.apiDateString
|
||||
}
|
||||
|
||||
var displayDate: String {
|
||||
if selectedDate.isToday {
|
||||
return "Today"
|
||||
var mealGroups: [MealGroup] {
|
||||
MealType.allCases.map { meal in
|
||||
MealGroup(
|
||||
meal: meal,
|
||||
entries: entries.filter { $0.mealType == meal.rawValue }
|
||||
)
|
||||
}
|
||||
return selectedDate.displayString
|
||||
}
|
||||
|
||||
var totalCalories: Double {
|
||||
entries.reduce(0) { $0 + $1.calories }
|
||||
}
|
||||
|
||||
var totalProtein: Double {
|
||||
entries.reduce(0) { $0 + $1.protein }
|
||||
}
|
||||
|
||||
var totalCarbs: Double {
|
||||
entries.reduce(0) { $0 + $1.carbs }
|
||||
}
|
||||
|
||||
var totalFat: Double {
|
||||
entries.reduce(0) { $0 + $1.fat }
|
||||
}
|
||||
|
||||
var caloriesRemaining: Double {
|
||||
max(goal.calories - totalCalories, 0)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func load() async {
|
||||
await repository.loadDay(date: dateString)
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
async let entriesTask = repo.entries(for: dateString, forceRefresh: true)
|
||||
async let goalsTask = repo.goals(for: dateString)
|
||||
|
||||
entries = try await entriesTask
|
||||
goal = try await goalsTask
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func previousDay() {
|
||||
selectedDate = Calendar.current.date(byAdding: .day, value: -1, to: selectedDate) ?? selectedDate
|
||||
func goToNextDay() {
|
||||
selectedDate = selectedDate.adding(days: 1)
|
||||
Task { await load() }
|
||||
}
|
||||
|
||||
func nextDay() {
|
||||
selectedDate = Calendar.current.date(byAdding: .day, value: 1, to: selectedDate) ?? selectedDate
|
||||
func goToPreviousDay() {
|
||||
selectedDate = selectedDate.adding(days: -1)
|
||||
Task { await load() }
|
||||
}
|
||||
|
||||
func goToToday() {
|
||||
selectedDate = Date()
|
||||
Task { await load() }
|
||||
}
|
||||
|
||||
func toggleMeal(_ meal: String) {
|
||||
if expandedMeals.contains(meal) {
|
||||
expandedMeals.remove(meal)
|
||||
} else {
|
||||
expandedMeals.insert(meal)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteEntry(_ entry: FoodEntry) async {
|
||||
await repository.deleteEntry(id: entry.id)
|
||||
// Optimistic removal
|
||||
entries.removeAll { $0.id == entry.id }
|
||||
do {
|
||||
try await repo.deleteEntry(id: entry.id, date: dateString)
|
||||
} catch {
|
||||
// Reload on failure
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
func updateEntryQuantity(id: String, quantity: Double) async {
|
||||
let request = UpdateEntryRequest(quantity: quantity)
|
||||
do {
|
||||
_ = try await repo.updateEntry(id: id, request: request, date: dateString)
|
||||
await load()
|
||||
} catch {
|
||||
errorMessage = "Failed to update entry"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,170 +2,238 @@ import SwiftUI
|
||||
|
||||
struct AddFoodSheet: View {
|
||||
let food: FoodItem
|
||||
let mealType: MealType
|
||||
let dateString: String
|
||||
let onAdded: () -> Void
|
||||
@Binding var quantity: Double
|
||||
@Binding var mealType: MealType
|
||||
let isAdding: Bool
|
||||
let onAdd: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var quantity: Double = 1.0
|
||||
@State private var selectedMeal: MealType
|
||||
@State private var isAdding = false
|
||||
|
||||
init(food: FoodItem, mealType: MealType, dateString: String, onAdded: @escaping () -> Void) {
|
||||
self.food = food
|
||||
self.mealType = mealType
|
||||
self.dateString = dateString
|
||||
self.onAdded = onAdded
|
||||
_selectedMeal = State(initialValue: mealType)
|
||||
}
|
||||
|
||||
private var scaledCalories: Double { food.calories * quantity }
|
||||
private var scaledProtein: Double { food.protein * quantity }
|
||||
private var scaledCarbs: Double { food.carbs * quantity }
|
||||
private var scaledFat: Double { food.fat * quantity }
|
||||
@State private var quantityText: String = "1"
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Food header
|
||||
VStack(spacing: 8) {
|
||||
if let img = food.imageFilename {
|
||||
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in
|
||||
image.resizable().aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Color.surfaceSecondary
|
||||
}
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
VStack(spacing: 24) {
|
||||
// Food info header
|
||||
foodHeader
|
||||
|
||||
Text(food.name)
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
// Quantity input
|
||||
quantitySection
|
||||
|
||||
if let serving = food.servingSize {
|
||||
Text(serving)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
// 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))
|
||||
}
|
||||
|
||||
// Quantity
|
||||
VStack(spacing: 8) {
|
||||
Text("Quantity")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
HStack(spacing: 16) {
|
||||
Button { if quantity > 0.5 { quantity -= 0.5 } } label: {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
Text(String(format: "%.1f", quantity))
|
||||
.font(.title2.bold())
|
||||
.frame(minWidth: 60)
|
||||
Button { quantity += 0.5 } label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Meal picker
|
||||
VStack(spacing: 8) {
|
||||
Text("Meal")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
HStack(spacing: 8) {
|
||||
ForEach(MealType.allCases) { meal in
|
||||
Button {
|
||||
selectedMeal = meal
|
||||
} label: {
|
||||
Text(meal.displayName)
|
||||
.font(.caption.bold())
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(selectedMeal == meal ? meal.color.opacity(0.2) : Color.surfaceSecondary)
|
||||
.foregroundStyle(selectedMeal == meal ? meal.color : Color.textSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nutrition preview
|
||||
VStack(spacing: 8) {
|
||||
nutritionRow("Calories", "\(Int(scaledCalories))")
|
||||
nutritionRow("Protein", "\(Int(scaledProtein))g")
|
||||
nutritionRow("Carbs", "\(Int(scaledCarbs))g")
|
||||
nutritionRow("Fat", "\(Int(scaledFat))g")
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
// Add button
|
||||
Button {
|
||||
isAdding = true
|
||||
Task {
|
||||
let req = CreateEntryRequest(
|
||||
foodId: food.id,
|
||||
foodName: food.name,
|
||||
mealType: selectedMeal.rawValue,
|
||||
quantity: quantity,
|
||||
entryDate: dateString,
|
||||
calories: food.calories,
|
||||
protein: food.protein,
|
||||
carbs: food.carbs,
|
||||
fat: food.fat,
|
||||
sugar: food.sugar,
|
||||
fiber: food.fiber
|
||||
)
|
||||
await FitnessRepository.shared.addEntry(req)
|
||||
isAdding = false
|
||||
dismiss()
|
||||
onAdded()
|
||||
}
|
||||
} label: {
|
||||
Group {
|
||||
if isAdding {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text("Add")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.accentWarm)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(Color.accentWarm)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.padding(20)
|
||||
.disabled(isAdding || quantity <= 0)
|
||||
}
|
||||
.padding(20)
|
||||
.background(Color.canvas)
|
||||
.navigationTitle("Add Food")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
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 func nutritionRow(_ label: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,159 +2,257 @@ import SwiftUI
|
||||
|
||||
struct EntryDetailView: View {
|
||||
let entry: FoodEntry
|
||||
let dateString: String
|
||||
let onDelete: () -> Void
|
||||
let onUpdateQuantity: (Double) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var quantity: Double
|
||||
@State private var isDeleting = false
|
||||
@State private var isSaving = false
|
||||
@State private var editQuantity: String
|
||||
@State private var showDeleteConfirm = false
|
||||
|
||||
init(entry: FoodEntry, dateString: String) {
|
||||
init(entry: FoodEntry, onDelete: @escaping () -> Void, onUpdateQuantity: @escaping (Double) -> Void) {
|
||||
self.entry = entry
|
||||
self.dateString = dateString
|
||||
_quantity = State(initialValue: entry.quantity)
|
||||
self.onDelete = onDelete
|
||||
self.onUpdateQuantity = onUpdateQuantity
|
||||
_editQuantity = State(initialValue: entry.quantity == entry.quantity.rounded() ? "\(Int(entry.quantity))" : String(format: "%.1f", entry.quantity))
|
||||
}
|
||||
|
||||
private var scaledCalories: Double { entry.calories * quantity }
|
||||
private var scaledProtein: Double { entry.protein * quantity }
|
||||
private var scaledCarbs: Double { entry.carbs * quantity }
|
||||
private var scaledFat: Double { entry.fat * quantity }
|
||||
private var scaledSugar: Double? { entry.sugar.map { $0 * quantity } }
|
||||
private var scaledFiber: Double? { entry.fiber.map { $0 * quantity } }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Food name
|
||||
Text(entry.foodName)
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
|
||||
// Meal badge
|
||||
HStack {
|
||||
Image(systemName: entry.mealType.icon)
|
||||
Text(entry.mealType.displayName)
|
||||
}
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(entry.mealType.color)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(entry.mealType.color.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
// Header
|
||||
entryHeader
|
||||
|
||||
// Quantity editor
|
||||
VStack(spacing: 8) {
|
||||
Text("Quantity")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
HStack(spacing: 16) {
|
||||
Button { if quantity > 0.5 { quantity -= 0.5 } } label: {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
Text(String(format: "%.1f", quantity))
|
||||
.font(.title2.bold())
|
||||
.frame(minWidth: 60)
|
||||
Button { quantity += 0.5 } label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
}
|
||||
}
|
||||
quantityEditor
|
||||
|
||||
if quantity != entry.quantity {
|
||||
Button {
|
||||
isSaving = true
|
||||
Task {
|
||||
_ = await FitnessRepository.shared.updateEntry(id: entry.id, quantity: quantity)
|
||||
isSaving = false
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
Group {
|
||||
if isSaving {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text("Update Quantity")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
// Macros grid
|
||||
macrosGrid
|
||||
|
||||
// Details
|
||||
detailsSection
|
||||
|
||||
// Delete button
|
||||
Button(role: .destructive) {
|
||||
showDeleteConfirm = true
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "trash")
|
||||
Text("Delete Entry")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.accentWarm)
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(Color.error)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.error.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// Nutrition grid
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
||||
nutritionCell("Calories", "\(Int(scaledCalories))", "kcal")
|
||||
nutritionCell("Protein", "\(Int(scaledProtein))", "g")
|
||||
nutritionCell("Carbs", "\(Int(scaledCarbs))", "g")
|
||||
nutritionCell("Fat", "\(Int(scaledFat))", "g")
|
||||
if let sugar = scaledSugar {
|
||||
nutritionCell("Sugar", "\(Int(sugar))", "g")
|
||||
}
|
||||
if let fiber = scaledFiber {
|
||||
nutritionCell("Fiber", "\(Int(fiber))", "g")
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
|
||||
// Delete
|
||||
Button(role: .destructive) {
|
||||
isDeleting = true
|
||||
Task {
|
||||
await FitnessRepository.shared.deleteEntry(id: entry.id)
|
||||
isDeleting = false
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
if isDeleting {
|
||||
ProgressView().tint(.red)
|
||||
} else {
|
||||
Image(systemName: "trash")
|
||||
Text("Delete Entry")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(Color.canvas)
|
||||
.navigationTitle("Entry Detail")
|
||||
.navigationTitle("Entry Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
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 func nutritionCell(_ label: String, _ value: String, _ unit: String) -> some View {
|
||||
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(value)
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text("\(label) (\(unit))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
Text("\(Int(value))")
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(color)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color.text3)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(12)
|
||||
.background(Color.surfaceSecondary)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +1,75 @@
|
||||
import SwiftUI
|
||||
|
||||
enum FitnessTab: String, CaseIterable {
|
||||
case today = "Today"
|
||||
case templates = "Templates"
|
||||
case goals = "Goals"
|
||||
case foods = "Foods"
|
||||
}
|
||||
|
||||
struct FitnessTabView: View {
|
||||
@State private var selectedTab: FitnessTab = .today
|
||||
@State private var showAssistant = false
|
||||
@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 {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
VStack(spacing: 0) {
|
||||
// Tab bar
|
||||
HStack(spacing: 24) {
|
||||
ForEach(FitnessTab.allCases, id: \.self) { tab in
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
selectedTab = tab
|
||||
}
|
||||
} label: {
|
||||
Text(tab.rawValue)
|
||||
.font(.subheadline)
|
||||
.fontWeight(selectedTab == tab ? .bold : .medium)
|
||||
.foregroundStyle(selectedTab == tab ? Color.accentWarm : Color.textSecondary)
|
||||
.padding(.bottom, 8)
|
||||
.overlay(alignment: .bottom) {
|
||||
if selectedTab == tab {
|
||||
Rectangle()
|
||||
.fill(Color.accentWarm)
|
||||
.frame(height: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
.padding(.top, 60)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// Content
|
||||
TabView(selection: $selectedTab) {
|
||||
TodayView(viewModel: todayVM)
|
||||
.tag(FitnessTab.today)
|
||||
TemplatesView(dateString: todayVM.dateString)
|
||||
.tag(FitnessTab.templates)
|
||||
GoalsView(dateString: todayVM.dateString)
|
||||
.tag(FitnessTab.goals)
|
||||
FoodLibraryView(dateString: todayVM.dateString)
|
||||
.tag(FitnessTab.foods)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
}
|
||||
|
||||
// Floating + button
|
||||
Button {
|
||||
showAssistant = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(Color.accentWarm)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: Color.accentWarm.opacity(0.3), radius: 8, y: 4)
|
||||
}
|
||||
.padding(.trailing, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.background(Color.canvas.ignoresSafeArea())
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.sheet(isPresented: $showAssistant) {
|
||||
AssistantChatView(entryDate: todayVM.dateString) {
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,119 +1,198 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FoodSearchView: View {
|
||||
let mealType: MealType
|
||||
let dateString: String
|
||||
let date: String
|
||||
let onFoodAdded: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var vm = FoodSearchViewModel()
|
||||
@State private var selectedFood: FoodItem?
|
||||
@State private var viewModel = FoodSearchViewModel()
|
||||
@FocusState private var searchFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Search bar
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
TextField("Search foods...", text: $vm.query)
|
||||
.textFieldStyle(.plain)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: vm.query) { _, _ in
|
||||
vm.search()
|
||||
}
|
||||
if !vm.query.isEmpty {
|
||||
Button { vm.query = ""; vm.results = [] } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
searchBar
|
||||
|
||||
if vm.isSearching || vm.isLoadingInitial {
|
||||
LoadingView()
|
||||
} else if !vm.query.isEmpty && vm.query.count >= 2 {
|
||||
// Search results
|
||||
List {
|
||||
Section {
|
||||
ForEach(vm.results) { food in
|
||||
foodRow(food)
|
||||
}
|
||||
} header: {
|
||||
Text("\(vm.results.count) results")
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
// 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 {
|
||||
// Default: recent + all
|
||||
List {
|
||||
if !vm.recentFoods.isEmpty {
|
||||
Section("Recent") {
|
||||
ForEach(vm.recentFoods) { food in
|
||||
foodRow(food)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !vm.allFoods.isEmpty {
|
||||
Section("All Foods") {
|
||||
ForEach(vm.allFoods) { food in
|
||||
foodRow(food)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
foodList
|
||||
}
|
||||
}
|
||||
.background(Color.canvas)
|
||||
.navigationTitle("Add Food")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundStyle(Color.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await vm.loadInitial()
|
||||
}
|
||||
.sheet(item: $selectedFood) { food in
|
||||
AddFoodSheet(food: food, mealType: mealType, dateString: dateString) {
|
||||
dismiss()
|
||||
.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 func foodRow(_ food: FoodItem) -> some View {
|
||||
Button {
|
||||
selectedFood = food
|
||||
} label: {
|
||||
HStack {
|
||||
if let img = food.imageFilename {
|
||||
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in
|
||||
image.resizable().aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Color.surfaceSecondary
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
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()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(food.name)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text("\(Int(food.calories)) cal")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
if !viewModel.searchText.isEmpty {
|
||||
Button {
|
||||
viewModel.searchText = ""
|
||||
viewModel.searchResults = []
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Color.text4)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "plus.circle")
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +1,139 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GoalsView: View {
|
||||
let dateString: String
|
||||
@State private var vm = GoalsViewModel()
|
||||
@State private var viewModel = GoalsViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
if vm.isLoading {
|
||||
LoadingView()
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
goalField("Calories", value: $vm.calories, unit: "kcal")
|
||||
goalField("Protein", value: $vm.protein, unit: "g")
|
||||
goalField("Carbs", value: $vm.carbs, unit: "g")
|
||||
goalField("Fat", value: $vm.fat, unit: "g")
|
||||
goalField("Sugar", value: $vm.sugar, unit: "g")
|
||||
goalField("Fiber", value: $vm.fiber, unit: "g")
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(.horizontal, 16)
|
||||
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)
|
||||
|
||||
Button {
|
||||
Task { await vm.save() }
|
||||
} label: {
|
||||
Group {
|
||||
if vm.isSaving {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text("Save Goals")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.accentWarm)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.disabled(vm.isSaving)
|
||||
// Goals cards
|
||||
goalCard(
|
||||
label: "Calories",
|
||||
value: viewModel.goal.calories,
|
||||
unit: "kcal",
|
||||
icon: "flame.fill",
|
||||
color: .caloriesColor
|
||||
)
|
||||
|
||||
if vm.saved {
|
||||
Text("Goals saved!")
|
||||
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.emerald)
|
||||
}
|
||||
|
||||
if let err = vm.error {
|
||||
ErrorBanner(message: err)
|
||||
.foregroundStyle(Color.text3)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
Spacer(minLength: 80)
|
||||
.padding(16)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
ErrorBanner(message: error) {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.load()
|
||||
}
|
||||
.task {
|
||||
await vm.load(date: dateString)
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
|
||||
private func goalField(_ label: String, value: Binding<String>, unit: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
.frame(width: 80, alignment: .leading)
|
||||
TextField("0", text: value)
|
||||
.keyboardType(.numberPad)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.subheadline.bold())
|
||||
.padding(10)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
Text(unit)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
.frame(width: 32)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
159
ios/Platform/Platform/Features/Fitness/Views/HistoryView.swift
Normal file
159
ios/Platform/Platform/Features/Fitness/Views/HistoryView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +1,261 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MealSectionView: View {
|
||||
let meal: MealType
|
||||
let entries: [FoodEntry]
|
||||
let mealCalories: Double
|
||||
let group: MealGroup
|
||||
let isExpanded: Bool
|
||||
let onToggle: () -> Void
|
||||
let onDelete: (FoodEntry) -> Void
|
||||
let dateString: String
|
||||
let onAddFood: () -> Void
|
||||
|
||||
@State private var isExpanded = true
|
||||
@State private var showFoodSearch = false
|
||||
@State private var selectedEntry: FoodEntry?
|
||||
|
||||
private var mealColor: Color {
|
||||
Color.mealColor(for: group.meal.rawValue)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 0) {
|
||||
// Colored accent bar
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(meal.color)
|
||||
.frame(width: 4, height: 44)
|
||||
.padding(.trailing, 12)
|
||||
|
||||
Image(systemName: meal.icon)
|
||||
.font(.headline)
|
||||
.foregroundStyle(meal.color)
|
||||
Button(action: onToggle) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: group.meal.icon)
|
||||
.font(.body)
|
||||
.foregroundStyle(mealColor)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(meal.displayName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text("\(entries.count) item\(entries.count == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
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())
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(Int(mealCalories)) cal")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(meal.color)
|
||||
if !group.entries.isEmpty {
|
||||
Text("\(Int(group.totalCalories)) kcal")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Color.text3)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||
.padding(.leading, 8)
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Color.text4)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Entries
|
||||
if isExpanded && !entries.isEmpty {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(entries) { entry in
|
||||
Button {
|
||||
selectedEntry = entry
|
||||
} label: {
|
||||
entryRow(entry)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
onDelete(entry)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 40)
|
||||
}
|
||||
|
||||
// Add button
|
||||
if isExpanded {
|
||||
Button {
|
||||
showFoodSearch = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundStyle(meal.color.opacity(0.6))
|
||||
Text("Add food")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
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)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.leading, 44)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(meal.color.opacity(0.03))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.sheet(isPresented: $showFoodSearch) {
|
||||
FoodSearchView(mealType: meal, dateString: dateString)
|
||||
}
|
||||
.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, dateString: dateString)
|
||||
EntryDetailView(
|
||||
entry: entry,
|
||||
onDelete: { onDelete(entry) },
|
||||
onUpdateQuantity: { _ in }
|
||||
)
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
}
|
||||
|
||||
private func entryRow(_ entry: FoodEntry) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(entry.foodName)
|
||||
private var emptyMealView: some View {
|
||||
Button(action: onAddFood) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "plus.circle")
|
||||
.foregroundStyle(mealColor)
|
||||
Text("Add food")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
.lineLimit(1)
|
||||
if entry.quantity != 1 {
|
||||
Text("x\(String(format: "%.1f", entry.quantity))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
.foregroundStyle(Color.text3)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(Int(entry.calories * entry.quantity))")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,102 +2,169 @@ import SwiftUI
|
||||
|
||||
struct TemplatesView: View {
|
||||
let dateString: String
|
||||
@State private var vm = TemplatesViewModel()
|
||||
@State private var templateToLog: MealTemplate?
|
||||
@State private var showConfirm = false
|
||||
let onTemplateLogged: () -> Void
|
||||
|
||||
@State private var viewModel = TemplatesViewModel()
|
||||
@State private var confirmTemplate: MealTemplate?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
if vm.isLoading {
|
||||
LoadingView()
|
||||
} else if vm.templates.isEmpty {
|
||||
EmptyStateView(icon: "doc.on.doc", title: "No templates", subtitle: "Create meal templates from the web app")
|
||||
} else {
|
||||
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 = vm.groupedByMeal[meal] ?? []
|
||||
let templates = viewModel.groupedTemplates[meal.rawValue] ?? []
|
||||
if !templates.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: meal.icon)
|
||||
.foregroundStyle(meal.color)
|
||||
Text(meal.displayName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
ForEach(templates) { template in
|
||||
templateCard(template)
|
||||
}
|
||||
}
|
||||
templateSection(meal: meal, templates: templates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let msg = vm.logSuccess {
|
||||
Text(msg)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.emerald)
|
||||
.padding(.top, 4)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
if let err = vm.error {
|
||||
ErrorBanner(message: err)
|
||||
}
|
||||
|
||||
Spacer(minLength: 80)
|
||||
.padding(16)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
ErrorBanner(message: error) {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.load()
|
||||
}
|
||||
.task {
|
||||
await vm.load()
|
||||
await viewModel.load()
|
||||
}
|
||||
.alert("Log Meal", isPresented: $showConfirm, presenting: templateToLog) { template in
|
||||
Button("Log") {
|
||||
Task { await vm.logTemplate(template, date: dateString) }
|
||||
.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("Add \(template.name) to today's log?")
|
||||
Text("This will add all items from \"\(template.name)\" (\(Int(template.calories)) kcal) to \(dateString).")
|
||||
}
|
||||
}
|
||||
|
||||
private func templateCard(_ template: MealTemplate) -> some View {
|
||||
HStack {
|
||||
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.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(Color.text1)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if let cal = template.totalCalories {
|
||||
Text("\(Int(cal)) cal")
|
||||
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 items = template.itemCount {
|
||||
Text("\(items) items")
|
||||
|
||||
if let protein = template.protein {
|
||||
Text("P\(Int(protein))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.proteinColor)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Button {
|
||||
templateToLog = template
|
||||
showConfirm = true
|
||||
} label: {
|
||||
Text("Log meal")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.accentWarm)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
// 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: 12))
|
||||
.shadow(color: .black.opacity(0.03), radius: 4, y: 1)
|
||||
.padding(.horizontal, 16)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,95 +2,170 @@ import SwiftUI
|
||||
|
||||
struct TodayView: View {
|
||||
@Bindable var viewModel: TodayViewModel
|
||||
@State private var showFoodSearch = false
|
||||
@Binding var showFoodSearch: Bool
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// Date selector
|
||||
HStack {
|
||||
Button { viewModel.previousDay() } label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
Spacer()
|
||||
Text(viewModel.displayDate)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Spacer()
|
||||
Button { viewModel.nextDay() } label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 50)
|
||||
.onEnded { value in
|
||||
if value.translation.width > 0 {
|
||||
viewModel.previousDay()
|
||||
} else {
|
||||
viewModel.nextDay()
|
||||
}
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Macro summary card
|
||||
if !viewModel.repository.isLoading {
|
||||
macroSummaryCard
|
||||
// Bottom spacing for FAB
|
||||
Spacer()
|
||||
.frame(height: 80)
|
||||
}
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
ErrorBanner(message: error) {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// Error
|
||||
if let error = viewModel.repository.error {
|
||||
ErrorBanner(message: error) { await viewModel.load() }
|
||||
}
|
||||
|
||||
// Meal sections
|
||||
ForEach(MealType.allCases) { meal in
|
||||
MealSectionView(
|
||||
meal: meal,
|
||||
entries: viewModel.repository.entriesForMeal(meal),
|
||||
mealCalories: viewModel.repository.mealCalories(meal),
|
||||
onDelete: { entry in
|
||||
Task { await viewModel.deleteEntry(entry) }
|
||||
},
|
||||
dateString: viewModel.dateString
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(minLength: 80)
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.load()
|
||||
.refreshable {
|
||||
await viewModel.load()
|
||||
}
|
||||
|
||||
// Floating add button
|
||||
addButton
|
||||
}
|
||||
.task {
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
|
||||
private var macroSummaryCard: some View {
|
||||
let repo = viewModel.repository
|
||||
let goals = repo.goals
|
||||
// MARK: - Date Selector
|
||||
|
||||
return VStack(spacing: 12) {
|
||||
HStack(spacing: 20) {
|
||||
LargeCalorieRing(consumed: repo.totalCalories, goal: goals.calories)
|
||||
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)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
MacroBar(label: "Protein", value: repo.totalProtein, goal: goals.protein, color: .blue)
|
||||
MacroBar(label: "Carbs", value: repo.totalCarbs, goal: goals.carbs, color: .orange)
|
||||
MacroBar(label: "Fat", value: repo.totalFat, goal: goals.fat, color: .purple)
|
||||
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(16)
|
||||
.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: 2)
|
||||
.padding(.horizontal, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,161 +1,188 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
struct HomeView: View {
|
||||
@Environment(AuthManager.self) private var authManager
|
||||
@State private var vm = HomeViewModel()
|
||||
@State private var showAssistant = false
|
||||
@State private var showProfileMenu = false
|
||||
@State private var selectedPhoto: PhotosPickerItem?
|
||||
@State private var viewModel = HomeViewModel()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
// Background
|
||||
if let bg = vm.backgroundImage {
|
||||
Image(uiImage: bg)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.ignoresSafeArea()
|
||||
.overlay(Color.black.opacity(0.2).ignoresSafeArea())
|
||||
}
|
||||
else {
|
||||
Color.canvas.ignoresSafeArea()
|
||||
}
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
if viewModel.isLoading {
|
||||
LoadingView(message: "Loading dashboard...")
|
||||
.frame(height: 300)
|
||||
} else {
|
||||
// Quick Stats Card
|
||||
caloriesSummaryCard
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// Top bar
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Dashboard")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(vm.backgroundImage != nil ? .white : Color.textPrimary)
|
||||
if let name = authManager.user?.displayName ?? authManager.user?.username {
|
||||
Text("Welcome, \(name)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(vm.backgroundImage != nil ? .white.opacity(0.8) : Color.textSecondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Menu {
|
||||
PhotosPicker(selection: $selectedPhoto, matching: .images) {
|
||||
Label("Change Background", systemImage: "photo")
|
||||
}
|
||||
if vm.backgroundImage != nil {
|
||||
Button(role: .destructive) {
|
||||
vm.removeBackground()
|
||||
} label: {
|
||||
Label("Remove Background", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive) {
|
||||
authManager.logout()
|
||||
} label: {
|
||||
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(vm.backgroundImage != nil ? .white : Color.accentWarm)
|
||||
}
|
||||
// Macros Card
|
||||
macrosCard
|
||||
|
||||
// Quick Actions
|
||||
quickActionsCard
|
||||
}
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
ErrorBanner(message: error) {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 60)
|
||||
|
||||
// Widget grid
|
||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
|
||||
calorieWidget
|
||||
quickStatsWidget
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Spacer(minLength: 100)
|
||||
}
|
||||
}
|
||||
|
||||
// Floating + button
|
||||
Button {
|
||||
showAssistant = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(Color.accentWarm)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: Color.accentWarm.opacity(0.3), radius: 8, y: 4)
|
||||
}
|
||||
.padding(.trailing, 20)
|
||||
.padding(.bottom, 20)
|
||||
.padding(16)
|
||||
}
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.sheet(isPresented: $showAssistant) {
|
||||
AssistantChatView(entryDate: Date().apiDateString) {
|
||||
Task { await vm.loadData() }
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedPhoto) { _, newValue in
|
||||
guard let item = newValue else { return }
|
||||
Task {
|
||||
if let data = try? await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) {
|
||||
vm.setBackground(image)
|
||||
.background(Color.canvas)
|
||||
.navigationTitle("Dashboard")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button(role: .destructive) {
|
||||
authManager.logout()
|
||||
} label: {
|
||||
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
selectedPhoto = nil
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.load()
|
||||
}
|
||||
.task {
|
||||
await vm.loadData()
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var calorieWidget: some View {
|
||||
let hasBg = vm.backgroundImage != nil
|
||||
return VStack(spacing: 8) {
|
||||
LargeCalorieRing(consumed: vm.caloriesConsumed, goal: vm.caloriesGoal)
|
||||
Text("Calories")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(hasBg ? .white : Color.textSecondary)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
if hasBg {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.ultraThinMaterial)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.surface)
|
||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 2)
|
||||
private var caloriesSummaryCard: some View {
|
||||
VStack(spacing: 16) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Today")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.text1)
|
||||
Text(Date().displayString)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.text3)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(viewModel.entryCount) entries")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.text4)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
MacroRingLarge(
|
||||
current: viewModel.totalCalories,
|
||||
goal: viewModel.goal.calories,
|
||||
color: .caloriesColor,
|
||||
size: 140,
|
||||
lineWidth: 12
|
||||
)
|
||||
|
||||
HStack(spacing: 0) {
|
||||
macroStat("Eaten", value: Int(viewModel.totalCalories), unit: "kcal")
|
||||
Spacer()
|
||||
macroStat("Remaining", value: Int(max(viewModel.goal.calories - viewModel.totalCalories, 0)), unit: "kcal")
|
||||
Spacer()
|
||||
macroStat("Goal", value: Int(viewModel.goal.calories), unit: "kcal")
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(Color.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
|
||||
}
|
||||
|
||||
private var quickStatsWidget: some View {
|
||||
let hasBg = vm.backgroundImage != nil
|
||||
let repo = FitnessRepository.shared
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
private var macrosCard: some View {
|
||||
VStack(spacing: 14) {
|
||||
Text("Macros")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(hasBg ? .white : Color.textSecondary)
|
||||
MacroBar(label: "Protein", value: repo.totalProtein, goal: repo.goals.protein, color: .blue, compact: true)
|
||||
MacroBar(label: "Carbs", value: repo.totalCarbs, goal: repo.goals.carbs, color: .orange, compact: true)
|
||||
MacroBar(label: "Fat", value: repo.totalFat, goal: repo.goals.fat, color: .purple, compact: true)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.text1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
MacroRing(
|
||||
current: viewModel.totalProtein,
|
||||
goal: viewModel.goal.protein,
|
||||
color: .proteinColor,
|
||||
label: "Protein",
|
||||
unit: "g",
|
||||
size: 68
|
||||
)
|
||||
MacroRing(
|
||||
current: viewModel.totalCarbs,
|
||||
goal: viewModel.goal.carbs,
|
||||
color: .carbsColor,
|
||||
label: "Carbs",
|
||||
unit: "g",
|
||||
size: 68
|
||||
)
|
||||
MacroRing(
|
||||
current: viewModel.totalFat,
|
||||
goal: viewModel.goal.fat,
|
||||
color: .fatColor,
|
||||
label: "Fat",
|
||||
unit: "g",
|
||||
size: 68
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
if hasBg {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.ultraThinMaterial)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.surface)
|
||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 2)
|
||||
.padding(20)
|
||||
.background(Color.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
|
||||
}
|
||||
|
||||
private var quickActionsCard: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("Quick Actions")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.text1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
quickActionButton(icon: "plus.circle.fill", label: "Log Food", color: .accentEmerald)
|
||||
quickActionButton(icon: "doc.text.fill", label: "Templates", color: .carbsColor)
|
||||
quickActionButton(icon: "clock.fill", label: "History", color: .accentWarm)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(Color.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
|
||||
}
|
||||
|
||||
private func macroStat(_ label: String, value: Int, unit: String) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text("\(value)")
|
||||
.font(.system(.title3, design: .rounded, weight: .bold))
|
||||
.foregroundStyle(Color.text1)
|
||||
Text("\(label)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color.text4)
|
||||
}
|
||||
}
|
||||
|
||||
private func quickActionButton(icon: String, label: String, color: Color) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.font(.title2)
|
||||
.foregroundStyle(color)
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(Color.text2)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(color.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,49 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
@MainActor @Observable
|
||||
final class HomeViewModel {
|
||||
var backgroundImage: UIImage?
|
||||
var caloriesConsumed: Double = 0
|
||||
var caloriesGoal: Double = 2000
|
||||
var isLoading = false
|
||||
var todayEntries: [FoodEntry] = []
|
||||
var goal: DailyGoal = .defaultGoal
|
||||
var isLoading = true
|
||||
var errorMessage: String?
|
||||
|
||||
private let bgKey = "homeBackgroundImage"
|
||||
private let repo = FitnessRepository.shared
|
||||
|
||||
init() {
|
||||
loadBackgroundFromDefaults()
|
||||
var totalCalories: Double {
|
||||
todayEntries.reduce(0) { $0 + $1.calories }
|
||||
}
|
||||
|
||||
func loadData() async {
|
||||
var totalProtein: Double {
|
||||
todayEntries.reduce(0) { $0 + $1.protein }
|
||||
}
|
||||
|
||||
var totalCarbs: Double {
|
||||
todayEntries.reduce(0) { $0 + $1.carbs }
|
||||
}
|
||||
|
||||
var totalFat: Double {
|
||||
todayEntries.reduce(0) { $0 + $1.fat }
|
||||
}
|
||||
|
||||
var entryCount: Int {
|
||||
todayEntries.count
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
let today = Date().apiDateString
|
||||
await repo.loadDay(date: today)
|
||||
caloriesConsumed = repo.totalCalories
|
||||
caloriesGoal = repo.goals.calories
|
||||
|
||||
do {
|
||||
async let entriesTask = repo.entries(for: today, forceRefresh: true)
|
||||
async let goalsTask = repo.goals(for: today)
|
||||
|
||||
todayEntries = try await entriesTask
|
||||
goal = try await goalsTask
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func setBackground(_ image: UIImage) {
|
||||
// Resize to max 1200px
|
||||
let maxDim: CGFloat = 1200
|
||||
let scale = min(maxDim / image.size.width, maxDim / image.size.height, 1.0)
|
||||
let newSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
|
||||
UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0)
|
||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||
let resized = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
|
||||
if let resized, let data = resized.jpegData(compressionQuality: 0.8) {
|
||||
UserDefaults.standard.set(data, forKey: bgKey)
|
||||
backgroundImage = resized
|
||||
}
|
||||
}
|
||||
|
||||
func removeBackground() {
|
||||
UserDefaults.standard.removeObject(forKey: bgKey)
|
||||
backgroundImage = nil
|
||||
}
|
||||
|
||||
private func loadBackgroundFromDefaults() {
|
||||
if let data = UserDefaults.standard.data(forKey: bgKey),
|
||||
let img = UIImage(data: data) {
|
||||
backgroundImage = img
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,41 +4,45 @@ struct LoadingView: View {
|
||||
var message: String = "Loading..."
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.tint(.accentWarm)
|
||||
.controlSize(.large)
|
||||
.tint(Color.accentWarm)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
.foregroundStyle(Color.text3)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.canvas)
|
||||
}
|
||||
}
|
||||
|
||||
struct ErrorBanner: View {
|
||||
let message: String
|
||||
var retry: (() async -> Void)?
|
||||
var onRetry: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.foregroundStyle(Color.error)
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
.foregroundStyle(Color.text2)
|
||||
|
||||
Spacer()
|
||||
if let retry = retry {
|
||||
|
||||
if let onRetry {
|
||||
Button("Retry") {
|
||||
Task { await retry() }
|
||||
onRetry()
|
||||
}
|
||||
.font(.subheadline.bold())
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.orange.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal)
|
||||
.background(Color.error.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,13 +55,15 @@ struct EmptyStateView: View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
.foregroundStyle(Color.text4)
|
||||
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
.foregroundStyle(Color.text2)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
.foregroundStyle(Color.text3)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(40)
|
||||
|
||||
@@ -2,43 +2,74 @@ import SwiftUI
|
||||
|
||||
struct MacroBar: View {
|
||||
let label: String
|
||||
let value: Double
|
||||
let current: Double
|
||||
let goal: Double
|
||||
let color: Color
|
||||
var compact: Bool = false
|
||||
var showGrams: Bool = true
|
||||
|
||||
private var progress: Double {
|
||||
guard goal > 0 else { return 0 }
|
||||
return min(max(value / goal, 0), 1)
|
||||
return min(current / goal, 1.0)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: compact ? 2 : 4) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(compact ? .caption2 : .caption)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
.foregroundStyle(Color.text3)
|
||||
Spacer()
|
||||
Text("\(Int(value))/\(Int(goal))g")
|
||||
.font(compact ? .caption2 : .caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
if showGrams {
|
||||
Text("\(Int(current))g / \(Int(goal))g")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.text3)
|
||||
} else {
|
||||
Text("\(Int(current)) / \(Int(goal))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.text3)
|
||||
}
|
||||
}
|
||||
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: compact ? 2 : 3)
|
||||
.fill(color.opacity(0.15))
|
||||
.frame(height: compact ? 4 : 6)
|
||||
Capsule()
|
||||
.fill(color.opacity(0.12))
|
||||
.frame(height: 6)
|
||||
|
||||
RoundedRectangle(cornerRadius: compact ? 2 : 3)
|
||||
Capsule()
|
||||
.fill(color)
|
||||
.frame(width: max(0, geo.size.width * progress), height: compact ? 4 : 6)
|
||||
.animation(.easeInOut(duration: 0.5), value: progress)
|
||||
.frame(width: geo.size.width * progress, height: 6)
|
||||
.animation(.easeOut(duration: 0.5), value: progress)
|
||||
}
|
||||
}
|
||||
.frame(height: compact ? 4 : 6)
|
||||
.frame(height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MacroBarCompact: View {
|
||||
let current: Double
|
||||
let goal: Double
|
||||
let color: Color
|
||||
|
||||
private var progress: Double {
|
||||
guard goal > 0 else { return 0 }
|
||||
return min(current / goal, 1.0)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(color.opacity(0.12))
|
||||
|
||||
Capsule()
|
||||
.fill(color)
|
||||
.frame(width: geo.size.width * progress)
|
||||
.animation(.easeOut(duration: 0.5), value: progress)
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +1,96 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MacroRing: View {
|
||||
let consumed: Double
|
||||
let current: Double
|
||||
let goal: Double
|
||||
let color: Color
|
||||
var size: CGFloat = 80
|
||||
var lineWidth: CGFloat = 8
|
||||
var showLabel: Bool = true
|
||||
var labelFontSize: CGFloat = 14
|
||||
let label: String
|
||||
let unit: String
|
||||
var size: CGFloat = 72
|
||||
var lineWidth: CGFloat = 7
|
||||
|
||||
private var progress: Double {
|
||||
guard goal > 0 else { return 0 }
|
||||
return min(max(consumed / goal, 0), 1)
|
||||
return min(current / goal, 1.0)
|
||||
}
|
||||
|
||||
private var remaining: Double {
|
||||
max(goal - current, 0)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(color.opacity(0.12), lineWidth: lineWidth)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(
|
||||
color,
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeOut(duration: 0.5), value: progress)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Text("\(Int(remaining))")
|
||||
.font(.system(size: size * 0.22, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Color.text1)
|
||||
Text("left")
|
||||
.font(.system(size: size * 0.13, weight: .medium))
|
||||
.foregroundStyle(Color.text4)
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(Color.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MacroRingLarge: View {
|
||||
let current: Double
|
||||
let goal: Double
|
||||
let color: Color
|
||||
var size: CGFloat = 120
|
||||
var lineWidth: CGFloat = 10
|
||||
|
||||
private var progress: Double {
|
||||
guard goal > 0 else { return 0 }
|
||||
return min(current / goal, 1.0)
|
||||
}
|
||||
|
||||
private var remaining: Double {
|
||||
max(goal - current, 0)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(color.opacity(0.15), lineWidth: lineWidth)
|
||||
.stroke(color.opacity(0.12), lineWidth: lineWidth)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||
.stroke(
|
||||
color,
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.6), value: progress)
|
||||
.animation(.easeOut(duration: 0.5), value: progress)
|
||||
|
||||
if showLabel {
|
||||
VStack(spacing: 0) {
|
||||
Text("\(Int(consumed))")
|
||||
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
if goal > 0 {
|
||||
Text("/ \(Int(goal))")
|
||||
.font(.system(size: labelFontSize * 0.65, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
}
|
||||
VStack(spacing: 2) {
|
||||
Text("\(Int(remaining))")
|
||||
.font(.system(size: size * 0.26, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Color.text1)
|
||||
Text("remaining")
|
||||
.font(.system(size: size * 0.11, weight: .medium))
|
||||
.foregroundStyle(Color.text4)
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct LargeCalorieRing: View {
|
||||
let consumed: Double
|
||||
let goal: Double
|
||||
|
||||
private var remaining: Int {
|
||||
max(0, Int(goal) - Int(consumed))
|
||||
}
|
||||
|
||||
private var progress: Double {
|
||||
guard goal > 0 else { return 0 }
|
||||
return min(max(consumed / goal, 0), 1)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.emerald.opacity(0.15), lineWidth: 14)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(
|
||||
Color.emerald,
|
||||
style: StrokeStyle(lineWidth: 14, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.8), value: progress)
|
||||
|
||||
VStack(spacing: 2) {
|
||||
Text("\(Int(consumed))")
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text("\(remaining) left")
|
||||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 120, height: 120)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,53 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
// Warm palette matching web app
|
||||
static let canvas = Color(hex: "F5EFE6")
|
||||
static let surface = Color.white
|
||||
static let surfaceSecondary = Color(hex: "F4F4F5")
|
||||
static let cardBackground = Color.white
|
||||
static let cardSecondary = Color(hex: "F4F4F5")
|
||||
|
||||
static let text1 = Color(hex: "18181B")
|
||||
static let text2 = Color(hex: "3F3F46")
|
||||
static let text3 = Color(hex: "71717A")
|
||||
static let text4 = Color(hex: "A1A1AA")
|
||||
|
||||
// Accent — warm amber/brown
|
||||
static let accentWarm = Color(hex: "8B6914")
|
||||
static let accentWarmBg = Color(hex: "FEF7E6")
|
||||
|
||||
// Emerald accent from web
|
||||
static let accentEmerald = Color(hex: "059669")
|
||||
static let accentEmeraldBg = Color(hex: "ECFDF5")
|
||||
|
||||
// Semantic
|
||||
static let success = Color(hex: "059669")
|
||||
static let error = Color(hex: "DC2626")
|
||||
static let warning = Color(hex: "D97706")
|
||||
|
||||
// Macro colors
|
||||
static let caloriesColor = Color(hex: "8B6914")
|
||||
static let proteinColor = Color(hex: "059669")
|
||||
static let carbsColor = Color(hex: "3B82F6")
|
||||
static let fatColor = Color(hex: "F59E0B")
|
||||
static let sugarColor = Color(hex: "EC4899")
|
||||
static let fiberColor = Color(hex: "8B5CF6")
|
||||
|
||||
// Meal colors
|
||||
static let breakfast = Color(hex: "F59E0B")
|
||||
static let lunch = Color(hex: "059669")
|
||||
static let dinner = Color(hex: "3B82F6")
|
||||
static let snack = Color(hex: "8B5CF6")
|
||||
|
||||
init(hex: String) {
|
||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var int: UInt64 = 0
|
||||
Scanner(string: hex).scanHexInt64(&int)
|
||||
let a, r, g, b: UInt64
|
||||
switch hex.count {
|
||||
case 3:
|
||||
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||
case 6:
|
||||
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||
case 8:
|
||||
@@ -23,25 +64,23 @@ extension Color {
|
||||
)
|
||||
}
|
||||
|
||||
// Core palette
|
||||
static let canvas = Color(hex: "F5EFE6")
|
||||
static let surface = Color(hex: "FFFFFF")
|
||||
static let surfaceSecondary = Color(hex: "FAF7F2")
|
||||
static let accentWarm = Color(hex: "8B6914")
|
||||
static let accentWarmLight = Color(hex: "D4A843")
|
||||
static let emerald = Color(hex: "059669")
|
||||
static let textPrimary = Color(hex: "1C1917")
|
||||
static let textSecondary = Color(hex: "78716C")
|
||||
static let textTertiary = Color(hex: "A8A29E")
|
||||
static let border = Color(hex: "E7E5E4")
|
||||
static func mealColor(for meal: String) -> Color {
|
||||
switch meal.lowercased() {
|
||||
case "breakfast": return .breakfast
|
||||
case "lunch": return .lunch
|
||||
case "dinner": return .dinner
|
||||
case "snack": return .snack
|
||||
default: return .text3
|
||||
}
|
||||
}
|
||||
|
||||
// Meal colors
|
||||
static let breakfastColor = Color(hex: "F59E0B")
|
||||
static let lunchColor = Color(hex: "059669")
|
||||
static let dinnerColor = Color(hex: "8B5CF6")
|
||||
static let snackColor = Color(hex: "EC4899")
|
||||
|
||||
// Chat
|
||||
static let userBubble = Color(hex: "8B6914").opacity(0.15)
|
||||
static let assistantBubble = Color(hex: "F5F5F4")
|
||||
static func mealIcon(for meal: String) -> String {
|
||||
switch meal.lowercased() {
|
||||
case "breakfast": return "sunrise.fill"
|
||||
case "lunch": return "sun.max.fill"
|
||||
case "dinner": return "moon.fill"
|
||||
case "snack": return "leaf.fill"
|
||||
default: return "fork.knife"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
/// Format as yyyy-MM-dd for API calls
|
||||
var apiDateString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
@@ -8,15 +9,24 @@ extension Date {
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
/// Display format: "Mon, Apr 2"
|
||||
var displayString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE, MMM d"
|
||||
formatter.dateFormat = "EEE, MMM d"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
/// Full display: "Monday, April 2, 2026"
|
||||
var fullDisplayString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .full
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
/// Short display: "Apr 2"
|
||||
var shortDisplayString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d, yyyy"
|
||||
formatter.dateFormat = "MMM d"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
@@ -24,10 +34,25 @@ extension Date {
|
||||
Calendar.current.isDateInToday(self)
|
||||
}
|
||||
|
||||
var isYesterday: Bool {
|
||||
Calendar.current.isDateInYesterday(self)
|
||||
}
|
||||
|
||||
func adding(days: Int) -> Date {
|
||||
Calendar.current.date(byAdding: .day, value: days, to: self) ?? self
|
||||
}
|
||||
|
||||
static func from(apiString: String) -> Date? {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter.date(from: apiString)
|
||||
}
|
||||
|
||||
/// Returns a label like "Today", "Yesterday", or the display string
|
||||
var relativeLabel: String {
|
||||
if isToday { return "Today" }
|
||||
if isYesterday { return "Yesterday" }
|
||||
return displayString
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user