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:
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor @Observable
|
||||
final class FoodSearchViewModel {
|
||||
var searchText = ""
|
||||
var searchResults: [FoodItem] = []
|
||||
var recentFoods: [FoodItem] = []
|
||||
var isSearching = false
|
||||
var isLoadingRecent = false
|
||||
var errorMessage: String?
|
||||
|
||||
// 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>?
|
||||
|
||||
var displayedFoods: [FoodItem] {
|
||||
if searchText.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
return recentFoods
|
||||
}
|
||||
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()
|
||||
|
||||
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 results = try await repo.searchFoods(query: query)
|
||||
guard !Task.isCancelled else { return }
|
||||
searchResults = results
|
||||
} catch {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor @Observable
|
||||
final class GoalsViewModel {
|
||||
var goal: DailyGoal = .defaultGoal
|
||||
var isLoading = true
|
||||
var errorMessage: String?
|
||||
|
||||
private let repo = FitnessRepository.shared
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
goal = try await repo.goals(for: Date().apiDateString, forceRefresh: true)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor @Observable
|
||||
final class TemplatesViewModel {
|
||||
var templates: [MealTemplate] = []
|
||||
var isLoading = true
|
||||
var errorMessage: String?
|
||||
var isLogging = false
|
||||
var loggedTemplateId: String?
|
||||
|
||||
private let repo = FitnessRepository.shared
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
templates = try await repo.templates(forceRefresh: true)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func logTemplate(_ template: MealTemplate, date: String, onComplete: @escaping () -> Void) async {
|
||||
isLogging = true
|
||||
loggedTemplateId = template.id
|
||||
|
||||
do {
|
||||
try await repo.logTemplate(id: template.id, date: date)
|
||||
loggedTemplateId = nil
|
||||
onComplete()
|
||||
} catch {
|
||||
errorMessage = "Failed to log template: \(error.localizedDescription)"
|
||||
loggedTemplateId = nil
|
||||
}
|
||||
|
||||
isLogging = false
|
||||
}
|
||||
|
||||
var groupedTemplates: [String: [MealTemplate]] {
|
||||
Dictionary(grouping: templates, by: \.mealType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor @Observable
|
||||
final class TodayViewModel {
|
||||
var entries: [FoodEntry] = []
|
||||
var goal: DailyGoal = .defaultGoal
|
||||
var selectedDate: Date = Date()
|
||||
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 mealGroups: [MealGroup] {
|
||||
MealType.allCases.map { meal in
|
||||
MealGroup(
|
||||
meal: meal,
|
||||
entries: entries.filter { $0.mealType == meal.rawValue }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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 goToNextDay() {
|
||||
selectedDate = selectedDate.adding(days: 1)
|
||||
Task { await load() }
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user