Files
FamilyMealPlanner/MealMood/Views/Home/WeekCalendarView.swift
T

414 lines
15 KiB
Swift

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<Int> {
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 ""
}
}
}