Version casi lista
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
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<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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 contentWidth = proxy.size.width - rowLabelWidth - (dayCount * spacing)
|
||||
let dayWidth = max(92, contentWidth / max(dayCount, 1))
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
Color.clear.frame(width: rowLabelWidth, height: 24)
|
||||
ForEach(Array(days.enumerated()), id: \.element) { index, day in
|
||||
dayHeaderCell(day: day, width: dayWidth, height: 24)
|
||||
if index < days.count - 1 {
|
||||
calendarDivider
|
||||
.padding(.horizontal, spacing / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
calendarHorizontalDivider
|
||||
|
||||
ForEach(Array(mealTypes.enumerated()), id: \.element) { rowIndex, mealType in
|
||||
HStack(spacing: 0) {
|
||||
mealTypeLabel(mealType)
|
||||
.frame(width: rowLabelWidth)
|
||||
.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)
|
||||
if index < days.count - 1 {
|
||||
calendarDivider
|
||||
.padding(.horizontal, spacing / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rowIndex < mealTypes.count - 1 {
|
||||
calendarHorizontalDivider
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.frame(minHeight: CGFloat(mealTypes.count) * 142 + 54)
|
||||
}
|
||||
|
||||
private var calendarDivider: some View {
|
||||
Rectangle()
|
||||
.fill(Color.mealMoodTextSecondary.opacity(0.22))
|
||||
.frame(width: 1)
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user