Add meal plan feature with cross-device sync and automatic stale data cleanup

Introduces weekly meal planning with a calendar-based tab view, per-recipe
date assignments synced via Nextcloud Cookbook custom metadata, and 30-day
automatic pruning of old entries on load, save, and sync merge.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 05:23:29 +01:00
parent 5890dbcad4
commit 8b23652f10
17 changed files with 1332 additions and 6 deletions

View File

@@ -0,0 +1,215 @@
//
// AddToMealPlanSheet.swift
// Nextcloud Cookbook iOS Client
//
import Foundation
import SwiftUI
struct AddToMealPlanSheet: View {
@EnvironmentObject var mealPlan: MealPlanManager
@Environment(\.dismiss) private var dismiss
let recipeId: String
let recipeName: String
let prepTime: String?
let recipeImage: UIImage?
@State private var weekOffset: Int = 0
@State private var selectedDays: Set<String> = []
private var calendar: Calendar { Calendar.current }
private var weekDates: [Date] {
let today = calendar.startOfDay(for: Date())
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 {
VStack(spacing: 0) {
// Recipe header
recipeHeader
.padding()
Divider()
// Week navigation
weekNavigationHeader
.padding(.horizontal)
.padding(.vertical, 8)
// Day rows with checkboxes
List {
ForEach(weekDates, id: \.self) { date in
let dayStr = MealPlanDate.dayString(from: date)
let isAlreadyAssigned = mealPlan.isRecipeAssigned(recipeId, on: date)
let existingCount = mealPlan.entries(for: date).count
Button {
if !isAlreadyAssigned {
if selectedDays.contains(dayStr) {
selectedDays.remove(dayStr)
} else {
selectedDays.insert(dayStr)
}
}
} label: {
HStack {
Image(systemName: (isAlreadyAssigned || selectedDays.contains(dayStr)) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isAlreadyAssigned ? Color.secondary : Color.nextcloudBlue)
Text(dayDisplayName(date))
.foregroundStyle(isAlreadyAssigned ? .secondary : .primary)
Spacer()
if existingCount > 0 {
Text("\(existingCount)")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Capsule().fill(Color(.tertiarySystemFill)))
}
}
}
.disabled(isAlreadyAssigned)
}
}
.listStyle(.plain)
}
.navigationTitle("Schedule Recipe")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
let dates = selectedDays.compactMap { MealPlanDate.dateFromDay($0) }
if !dates.isEmpty {
mealPlan.assignRecipe(recipeId: recipeId, recipeName: recipeName, toDates: dates)
}
dismiss()
}
.disabled(selectedDays.isEmpty)
}
}
}
}
private var recipeHeader: some View {
HStack(spacing: 12) {
if let recipeImage {
Image(uiImage: recipeImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 60, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 10))
} else {
LinearGradient(
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(width: 60, height: 60)
.overlay {
Image(systemName: "fork.knife")
.foregroundStyle(.white.opacity(0.7))
}
.clipShape(RoundedRectangle(cornerRadius: 10))
}
VStack(alignment: .leading, spacing: 4) {
Text(recipeName)
.font(.headline)
.lineLimit(2)
if let prepTime, !prepTime.isEmpty {
let duration = DurationComponents.fromPTString(prepTime)
if duration.hourComponent > 0 || duration.minuteComponent > 0 {
HStack(spacing: 4) {
Image(systemName: "clock")
.font(.caption)
Text(duration.displayString)
.font(.caption)
}
.foregroundStyle(.secondary)
}
}
}
Spacer()
}
}
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)
}
}
}
private func dayDisplayName(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, d MMM"
let name = formatter.string(from: date)
if calendar.isDateInToday(date) {
return "\(name) (\(String(localized: "Today")))"
}
return name
}
}