Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Views/Recipes/AddToMealPlanSheet.swift
Hendrik Hogertz 8b23652f10 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>
2026-02-15 05:23:29 +01:00

216 lines
7.6 KiB
Swift
Raw 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.
//
// 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
}
}