414 lines
15 KiB
Swift
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 ""
|
|
}
|
|
}
|
|
}
|