Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Views/Tabs/MealPlanTabView.swift
Hendrik Hogertz 1f7f19c74b Fix meal plan not populating on first login and add pull-to-refresh sync
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>
2026-02-15 11:40:31 +01:00

416 lines
14 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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 }
}