import SwiftUI struct WeekCalendarView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass let plan: WeekPlan let settings: AppSettings let dishes: [Dish] let tags: [Tag] @ObservedObject var viewModel: HomeViewModel var onTapEmptySlot: ((MealSlot) -> Void)? private var dayRange: ClosedRange { settings.includeWeekends ? 0...6 : 0...4 } private var days: [Int] { Array(dayRange) } private var mealTypes: [MealType] { switch settings.mealWindowsEnum { case .dinnerOnly: return [.dinner] case .lunchOnly: return [.lunch] case .both: return [.lunch, .dinner] } } var body: some View { Group { if horizontalSizeClass == .regular { regularGridLayout } else { compactLayout } } } private var compactLayout: some View { ScrollView(.horizontal, showsIndicators: false) { VStack(spacing: 0) { HStack(spacing: 0) { HStack(spacing: 0) { ForEach(Array(days.enumerated()), id: \.element) { index, day in dayHeaderCell(day: day, width: 96, height: 24) if index < days.count - 1 { calendarDivider() } } } } calendarHorizontalDivider ForEach(Array(mealTypes.enumerated()), id: \.element) { index, mealType in HStack(spacing: 0) { ForEach(Array(days.enumerated()), id: \.element) { index, day in let slot = slotFor(day: day, mealType: mealType) draggableSlotView(slot: slot, mealType: mealType) .frame(width: 96) if index < days.count - 1 { calendarDivider() } } } if index < mealTypes.count - 1 { calendarHorizontalDivider } } } .padding(.horizontal, 16) } } private var regularGridLayout: some View { GeometryReader { proxy in let dayCount = CGFloat(dayRange.count) let spacing: CGFloat = 10 let rowLabelWidth: CGFloat = 86 let headerHeight: CGFloat = 26 let slotRowHeight: CGFloat = 96 let verticalInset: CGFloat = 2 let contentWidth = proxy.size.width - rowLabelWidth - (dayCount * spacing) let dayWidth = max(92, contentWidth / max(dayCount, 1)) let totalHeight = (verticalInset * 2) + headerHeight + 1 + (CGFloat(mealTypes.count) * slotRowHeight) + CGFloat(max(0, mealTypes.count - 1)) VStack(spacing: 0) { HStack(spacing: 0) { Color.clear.frame(width: rowLabelWidth, height: headerHeight) ForEach(Array(days.enumerated()), id: \.element) { index, day in dayHeaderCell(day: day, width: dayWidth, height: headerHeight) if index < days.count - 1 { calendarDivider(height: headerHeight) .padding(.horizontal, spacing / 2) } } } calendarHorizontalDivider ForEach(Array(mealTypes.enumerated()), id: \.element) { rowIndex, mealType in HStack(spacing: 0) { mealTypeLabel(mealType) .frame(width: rowLabelWidth, height: slotRowHeight) .padding(.trailing, spacing) ForEach(Array(days.enumerated()), id: \.element) { index, day in let slot = slotFor(day: day, mealType: mealType) draggableSlotView(slot: slot, mealType: mealType) .frame(width: dayWidth, height: slotRowHeight) if index < days.count - 1 { calendarDivider(height: slotRowHeight) .padding(.horizontal, spacing / 2) } } } if rowIndex < mealTypes.count - 1 { calendarHorizontalDivider } } } .padding(.horizontal, 16) .padding(.vertical, verticalInset) .frame(height: totalHeight, alignment: .top) } .frame(height: CGFloat(mealTypes.count) * 98 + 44) } private func calendarDivider(height: CGFloat? = nil) -> some View { Rectangle() .fill(Color.mealMoodTextSecondary.opacity(0.22)) .frame(width: 1) .frame(height: height) } private var calendarHorizontalDivider: some View { Rectangle() .fill(Color.mealMoodTextSecondary.opacity(0.22)) .frame(height: 1) } private func dayHeader(day: Int) -> some View { HStack(spacing: 4) { Text(LocalizedStringKey(dayKey(for: day))) .font(.mealMoodCaption) .foregroundColor(.mealMoodTextPrimary) .fontWeight(.semibold) let dayDate = plan.weekStartDate.addingDays(day) Text("\(Calendar.current.component(.day, from: dayDate))") .font(.mealMoodSmall) .foregroundColor(dayDate.isToday ? .mealMoodCoral : .mealMoodTextPrimary) .fontWeight(dayDate.isToday ? .bold : .regular) } } private func dayHeaderCell(day: Int, width: CGFloat, height: CGFloat) -> some View { dayHeader(day: day) .frame(width: width, height: height) .background(Color.mealMoodCoral.opacity(0.28)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.mealMoodCoral.opacity(0.45), lineWidth: 1) ) .cornerRadius(8) } private func mealTypeLabel(_ mealType: MealType) -> some View { VStack(spacing: 6) { Image(systemName: mealType.icon) .font(.system(size: 16, weight: .semibold)) .foregroundColor(.mealMoodCoral) Text(LocalizedStringKey(mealType.rawValue)) .font(.mealMoodCaption) .foregroundColor(.mealMoodTextSecondary) } .padding(.vertical, 8) .background(Color.mealMoodSurface) .cornerRadius(10) } private func slotFor(day: Int, mealType: MealType) -> MealSlot? { plan.slots.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue } } private func draggableSlotView(slot: MealSlot?, mealType: MealType) -> some View { slotView(slot: slot, mealType: mealType) .dropDestination(for: String.self) { items, _ in guard let payloadRaw = items.first, let payload = parseDropPayload(payloadRaw), let targetSlot = slot, viewModel.canEditCurrentWeek else { return false } switch payload { case .dish(let dishId): guard let dish = dishes.first(where: { $0.id == dishId }) else { return false } let isValid = AutocompleteEngine.validateDrop( dish: dish, slot: targetSlot, plan: plan, allTags: tags, allDishes: dishes ) if isValid { viewModel.assignDish(dish, to: targetSlot, plan: plan, settings: settings) return true } viewModel.confirmInvalidDrop(dish, to: targetSlot, plan: plan, settings: settings) return true case .slot(let sourceSlotId): guard let sourceSlot = plan.slots.first(where: { $0.id == sourceSlotId }) else { return false } viewModel.moveOrSwapDish( from: sourceSlot, to: targetSlot, plan: plan, settings: settings, allTags: tags, allDishes: dishes ) return true } } } @ViewBuilder private func slotView(slot: MealSlot?, mealType: MealType) -> some View { if let slot = slot, let dishId = slot.dishId, let dish = dishes.first(where: { $0.id == dishId }) { let dishTags = tags.filter { dish.tagIds.contains($0.id) } FilledSlotView( dishName: dish.name, dishDescription: dish.descriptionText, tags: dishTags.map { (name: $0.localizedName(language: settings.languageEnum.resolved()), color: $0.color) }, mealType: mealType, showsRuleWarning: slot.isRuleOverridden, onRemove: viewModel.canEditCurrentWeek ? { viewModel.removeDish(from: slot, plan: plan, settings: settings) } : nil ) .draggable("slot:\(slot.id.uuidString)") } else if let slot = slot { EmptySlotView(mealType: mealType) .contentShape(Rectangle()) .onTapGesture { guard viewModel.canEditCurrentWeek else { return } onTapEmptySlot?(slot) } } else { EmptySlotView(mealType: mealType) } } private enum DropPayload { case dish(UUID) case slot(UUID) } private func parseDropPayload(_ raw: String) -> DropPayload? { let parts = raw.split(separator: ":", maxSplits: 1).map(String.init) guard parts.count == 2, let id = UUID(uuidString: parts[1]) else { return nil } switch parts[0] { case "dish": return .dish(id) case "slot": return .slot(id) default: // Backward compatibility for older draggable payloads that only sent dish UUID. if let fallbackDishId = UUID(uuidString: raw) { return .dish(fallbackDishId) } return nil } } private func dayKey(for dayIndex: Int) -> String { switch dayIndex { case 0: return "day_mon" case 1: return "day_tue" case 2: return "day_wed" case 3: return "day_thu" case 4: return "day_fri" case 5: return "day_sat" case 6: return "day_sun" default: return "" } } }