import SwiftUI struct WeekCalendarView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var dishSnapshotsById: [UUID: DishSnapshot] = [:] @State private var displaySlotsByKey: [String: DisplaySlot] = [:] 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 } } .onAppear { refreshDisplaySlots() refreshDishSnapshots() } .onChange(of: dishesSnapshotKey) { _, _ in refreshDishSnapshots() } .onChange(of: plan.updatedAt) { _, _ in refreshDisplaySlots() refreshDishSnapshots() } .onChange(of: settings.includeWeekends) { _, _ in refreshDisplaySlots() } .onChange(of: settings.mealWindows) { _, _ in refreshDisplaySlots() } } 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 = displayedSlot(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 = displayedSlot(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 displayedSlot(day: Int, mealType: MealType) -> DisplaySlot? { let key = slotKey(day: day, mealType: mealType.rawValue) if let cached = displaySlotsByKey[key] { return cached } let matching = plan.slots.filter { $0.dayOfWeek == day && $0.mealType == mealType.rawValue } guard !matching.isEmpty else { return nil } let preferred = MealSlot.preferredForDuplicateResolution(matching) return DisplaySlot( slotId: preferred.id, dayOfWeek: day, mealType: mealType.rawValue, dishId: preferred.dishId, isRuleOverridden: preferred.isRuleOverridden ) } private func draggableSlotView(slot: DisplaySlot?, 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 targetDisplaySlot = slot, let targetSlot = liveSlot(for: targetDisplaySlot), 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: DisplaySlot?, mealType: MealType) -> some View { if let slot = slot, let dishId = slot.dishId, let dishData = dishData(for: dishId) { let dishTags = tags.filter { dishData.tagIds.contains($0.id) } FilledSlotView( dishName: dishData.name, dishDescription: dishData.descriptionText, tags: dishTags.map { (name: $0.localizedName(language: settings.languageEnum.resolved()), color: $0.color) }, mealType: mealType, showsRuleWarning: slot.isRuleOverridden, onRemove: viewModel.canEditCurrentWeek ? { guard let live = liveSlot(for: slot) else { return } viewModel.removeDish(from: live, plan: plan, settings: settings) } : nil ) .draggable("slot:\(slot.slotId.uuidString)") } else if let slot = slot { EmptySlotView(mealType: mealType) .contentShape(Rectangle()) .onTapGesture { guard viewModel.canEditCurrentWeek else { return } guard let live = liveSlot(for: slot) else { return } onTapEmptySlot?(live) } } else { EmptySlotView(mealType: mealType) } } private enum DropPayload { case dish(UUID) case slot(UUID) } private struct DishSnapshot { let name: String let descriptionText: String? let tagIds: [UUID] } private struct DisplaySlot { let slotId: UUID let dayOfWeek: Int let mealType: String let dishId: UUID? let isRuleOverridden: Bool } private var dishesSnapshotKey: String { dishes .map { "\($0.id.uuidString)|\($0.name)|\($0.tagIds.count)|\($0.descriptionText ?? "")" } .joined(separator: ";") } private func dishData(for dishId: UUID) -> DishSnapshot? { if let live = dishes.first(where: { $0.id == dishId }) { return DishSnapshot( name: live.name, descriptionText: live.descriptionText, tagIds: live.tagIds ) } return dishSnapshotsById[dishId] } private func refreshDishSnapshots() { let liveSnapshots = Dictionary(uniqueKeysWithValues: dishes.map { dish in ( dish.id, DishSnapshot( name: dish.name, descriptionText: dish.descriptionText, tagIds: dish.tagIds ) ) }) dishSnapshotsById = dishSnapshotsById.merging(liveSnapshots) { _, new in new } } private func refreshDisplaySlots() { var resolved: [String: DisplaySlot] = [:] let grouped = Dictionary(grouping: plan.slots, by: { slotKey(day: $0.dayOfWeek, mealType: $0.mealType) }) for (key, candidates) in grouped { guard !candidates.isEmpty else { continue } let preferred = MealSlot.preferredForDuplicateResolution(candidates) resolved[key] = DisplaySlot( slotId: preferred.id, dayOfWeek: preferred.dayOfWeek, mealType: preferred.mealType, dishId: preferred.dishId, isRuleOverridden: preferred.isRuleOverridden ) } // Ignore incomplete transient relationship states that occur during SwiftData refreshes. if resolved.count == expectedSlotCount || displaySlotsByKey.isEmpty { displaySlotsByKey = resolved } } private var expectedSlotCount: Int { days.count * mealTypes.count } private func slotKey(day: Int, mealType: String) -> String { "\(day)|\(mealType)" } private func liveSlot(for display: DisplaySlot) -> MealSlot? { if let exact = plan.slots.first(where: { $0.id == display.slotId }) { return exact } let matching = plan.slots.filter { $0.dayOfWeek == display.dayOfWeek && $0.mealType == display.mealType } guard !matching.isEmpty else { return nil } return MealSlot.preferredForDuplicateResolution(matching) } 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 "" } } }