// // 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") .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.1)) ) } } } .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 } }