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