Rewrite MealPlanSyncManager.performSync() (renamed from performInitialSync) to discover _mealPlanAssignment metadata from all server recipes, not just locally- known ones. On first sync all recipes are checked; on subsequent syncs only recipes modified since lastMealPlanSyncDate are fetched (max 5 concurrent). Trigger meal plan sync from pull-to-refresh on both the recipe and meal plan tabs, and from the "Refresh all" toolbar button. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
416 lines
14 KiB
Swift
416 lines
14 KiB
Swift
//
|
||
// MealPlanTabView.swift
|
||
// Nextcloud Cookbook iOS Client
|
||
//
|
||
|
||
import Foundation
|
||
import SwiftUI
|
||
|
||
struct MealPlanTabView: View {
|
||
@EnvironmentObject var mealPlan: MealPlanManager
|
||
@EnvironmentObject var appState: AppState
|
||
@EnvironmentObject var groceryList: GroceryListManager
|
||
|
||
@State private var weekOffset: Int = 0
|
||
@State private var addRecipeDate: Date? = nil
|
||
|
||
private var calendar: Calendar { Calendar.current }
|
||
|
||
private var weekDates: [Date] {
|
||
let today = calendar.startOfDay(for: Date())
|
||
// Find start of current week (Monday)
|
||
let weekday = calendar.component(.weekday, from: today)
|
||
let daysToMonday = (weekday + 5) % 7
|
||
guard let monday = calendar.date(byAdding: .day, value: -daysToMonday, to: today),
|
||
let offsetMonday = calendar.date(byAdding: .weekOfYear, value: weekOffset, to: monday) else {
|
||
return []
|
||
}
|
||
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: offsetMonday) }
|
||
}
|
||
|
||
private var weekLabel: String {
|
||
if weekOffset == 0 {
|
||
return String(localized: "This Week")
|
||
} else if weekOffset == 1 {
|
||
return String(localized: "Next Week")
|
||
} else if weekOffset == -1 {
|
||
return String(localized: "Last Week")
|
||
} else {
|
||
return weekRangeString
|
||
}
|
||
}
|
||
|
||
private var weekRangeString: String {
|
||
guard let first = weekDates.first, let last = weekDates.last else { return "" }
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "dd.MM."
|
||
return "\(formatter.string(from: first)) – \(formatter.string(from: last))"
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ScrollView {
|
||
VStack(spacing: 0) {
|
||
weekNavigationHeader
|
||
.padding(.horizontal)
|
||
.padding(.vertical, 8)
|
||
|
||
ForEach(weekDates, id: \.self) { date in
|
||
MealPlanDayRow(
|
||
date: date,
|
||
entries: mealPlan.entries(for: date),
|
||
isToday: calendar.isDateInToday(date),
|
||
onAdd: {
|
||
addRecipeDate = date
|
||
},
|
||
onRemove: { entry in
|
||
withAnimation {
|
||
mealPlan.removeRecipe(recipeId: entry.recipeId, fromDate: entry.dateString)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Meal Plan")
|
||
.refreshable {
|
||
await appState.getCategories()
|
||
for category in appState.categories {
|
||
await appState.getCategory(named: category.name, fetchMode: .preferServer)
|
||
}
|
||
if UserSettings.shared.mealPlanSyncEnabled {
|
||
await mealPlan.syncManager?.performSync()
|
||
}
|
||
}
|
||
.navigationDestination(for: Recipe.self) { recipe in
|
||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||
.environmentObject(appState)
|
||
.environmentObject(groceryList)
|
||
.environmentObject(mealPlan)
|
||
}
|
||
.sheet(item: $addRecipeDate) { date in
|
||
RecipePickerForMealPlan(date: date)
|
||
.environmentObject(mealPlan)
|
||
.environmentObject(appState)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var weekNavigationHeader: some View {
|
||
HStack {
|
||
Button {
|
||
withAnimation { weekOffset -= 1 }
|
||
} label: {
|
||
Image(systemName: "chevron.left")
|
||
.font(.title3)
|
||
.foregroundStyle(Color.nextcloudBlue)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
VStack(spacing: 2) {
|
||
Text(weekLabel)
|
||
.font(.headline)
|
||
if weekOffset == 0 || weekOffset == 1 || weekOffset == -1 {
|
||
Text(weekRangeString)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
|
||
Button {
|
||
withAnimation { weekOffset += 1 }
|
||
} label: {
|
||
Image(systemName: "chevron.right")
|
||
.font(.title3)
|
||
.foregroundStyle(Color.nextcloudBlue)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Day Row
|
||
|
||
fileprivate struct MealPlanDayRow: View {
|
||
let date: Date
|
||
let entries: [MealPlanEntry]
|
||
let isToday: Bool
|
||
let onAdd: () -> Void
|
||
let onRemove: (MealPlanEntry) -> Void
|
||
|
||
private var dayNumber: String {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "d"
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
private var dayName: String {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "EEE"
|
||
return formatter.string(from: date).uppercased()
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(alignment: .center, spacing: 12) {
|
||
// Day label
|
||
VStack(spacing: 2) {
|
||
Text(dayName)
|
||
.font(.caption2)
|
||
.fontWeight(.medium)
|
||
.foregroundStyle(isToday ? .white : .secondary)
|
||
Text(dayNumber)
|
||
.font(.title3)
|
||
.fontWeight(.semibold)
|
||
.foregroundStyle(isToday ? .white : .primary)
|
||
}
|
||
.frame(width: 44, height: 54)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 10)
|
||
.fill(isToday ? Color.nextcloudBlue : Color.clear)
|
||
)
|
||
|
||
// Entry or add button
|
||
if let entry = entries.first, let recipeIdInt = Int(entry.recipeId) {
|
||
NavigationLink(value: Recipe(
|
||
name: entry.recipeName,
|
||
keywords: nil,
|
||
dateCreated: nil,
|
||
dateModified: nil,
|
||
imageUrl: nil,
|
||
imagePlaceholderUrl: nil,
|
||
recipe_id: recipeIdInt
|
||
)) {
|
||
MealPlanEntryCard(entry: entry, onRemove: {
|
||
onRemove(entry)
|
||
})
|
||
}
|
||
.buttonStyle(.plain)
|
||
} else if let entry = entries.first {
|
||
MealPlanEntryCard(entry: entry, onRemove: {
|
||
onRemove(entry)
|
||
})
|
||
} else {
|
||
Button(action: onAdd) {
|
||
Image(systemName: "plus")
|
||
.font(.subheadline)
|
||
.foregroundStyle(Color.nextcloudBlue)
|
||
.frame(maxWidth: .infinity, minHeight: 44)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.fill(Color.nextcloudBlue.opacity(0.15))
|
||
)
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal)
|
||
.padding(.vertical, 8)
|
||
|
||
Divider()
|
||
.padding(.leading, 68)
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Entry Card
|
||
|
||
fileprivate struct MealPlanEntryCard: View {
|
||
@EnvironmentObject var appState: AppState
|
||
let entry: MealPlanEntry
|
||
let onRemove: () -> Void
|
||
|
||
@State private var recipeThumb: UIImage?
|
||
@State private var totalTimeText: String?
|
||
|
||
var body: some View {
|
||
HStack(spacing: 8) {
|
||
if let recipeThumb {
|
||
Image(uiImage: recipeThumb)
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fill)
|
||
.frame(width: 44)
|
||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||
} else {
|
||
LinearGradient(
|
||
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
.frame(width: 44)
|
||
.overlay {
|
||
Image(systemName: "fork.knife")
|
||
.font(.caption2)
|
||
.foregroundStyle(.white.opacity(0.7))
|
||
}
|
||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(entry.recipeName)
|
||
.font(.subheadline)
|
||
.lineLimit(3)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
if let totalTimeText {
|
||
HStack(spacing: 3) {
|
||
Image(systemName: "clock")
|
||
.font(.caption2)
|
||
Text(totalTimeText)
|
||
.font(.caption2)
|
||
}
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
Spacer(minLength: 0)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(6)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.fill(Color(.secondarySystemBackground))
|
||
)
|
||
.contextMenu {
|
||
Button(role: .destructive) {
|
||
onRemove()
|
||
} label: {
|
||
Label("Remove", systemImage: "trash")
|
||
}
|
||
}
|
||
.task {
|
||
guard let recipeIdInt = Int(entry.recipeId) else { return }
|
||
recipeThumb = await appState.getImage(
|
||
id: recipeIdInt,
|
||
size: .THUMB,
|
||
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
||
)
|
||
if let detail = await appState.getRecipe(
|
||
id: recipeIdInt,
|
||
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
|
||
) {
|
||
if let totalTime = detail.totalTime, let text = DurationComponents.ptToText(totalTime) {
|
||
totalTimeText = text
|
||
} else if let prepTime = detail.prepTime, let text = DurationComponents.ptToText(prepTime) {
|
||
totalTimeText = text
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Recipe Picker Sheet
|
||
|
||
struct RecipePickerForMealPlan: View {
|
||
@EnvironmentObject var mealPlan: MealPlanManager
|
||
@EnvironmentObject var appState: AppState
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
let date: Date
|
||
|
||
@State private var searchText = ""
|
||
@State private var allRecipes: [Recipe] = []
|
||
|
||
private var filteredRecipes: [Recipe] {
|
||
if searchText.isEmpty {
|
||
return allRecipes
|
||
}
|
||
return allRecipes.filter {
|
||
$0.name.localizedCaseInsensitiveContains(searchText)
|
||
}
|
||
}
|
||
|
||
private var dateLabel: String {
|
||
let formatter = DateFormatter()
|
||
formatter.dateStyle = .medium
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
List {
|
||
ForEach(filteredRecipes, id: \.recipe_id) { recipe in
|
||
Button {
|
||
mealPlan.assignRecipe(
|
||
recipeId: String(recipe.recipe_id),
|
||
recipeName: recipe.name,
|
||
toDates: [date]
|
||
)
|
||
dismiss()
|
||
} label: {
|
||
RecipePickerRow(recipe: recipe)
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle(dateLabel)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.searchable(text: $searchText, prompt: String(localized: "Search recipes"))
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("Cancel") { dismiss() }
|
||
}
|
||
}
|
||
}
|
||
.task {
|
||
allRecipes = await appState.getRecipes()
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Recipe Picker Row
|
||
|
||
fileprivate struct RecipePickerRow: View {
|
||
@EnvironmentObject var appState: AppState
|
||
let recipe: Recipe
|
||
@State private var recipeThumb: UIImage?
|
||
|
||
var body: some View {
|
||
HStack(spacing: 10) {
|
||
if let recipeThumb {
|
||
Image(uiImage: recipeThumb)
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fill)
|
||
.frame(width: 48, height: 48)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
} else {
|
||
LinearGradient(
|
||
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
.frame(width: 48, height: 48)
|
||
.overlay {
|
||
Image(systemName: "fork.knife")
|
||
.font(.caption)
|
||
.foregroundStyle(.white.opacity(0.7))
|
||
}
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
|
||
Text(recipe.name)
|
||
.font(.subheadline)
|
||
.foregroundStyle(.primary)
|
||
.lineLimit(2)
|
||
|
||
Spacer()
|
||
}
|
||
.task {
|
||
recipeThumb = await appState.getImage(
|
||
id: recipe.recipe_id,
|
||
size: .THUMB,
|
||
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Date Identifiable Extension
|
||
|
||
extension Date: @retroactive Identifiable {
|
||
public var id: TimeInterval { timeIntervalSince1970 }
|
||
}
|