Version casi lista

This commit is contained in:
alexandrev-tibco
2026-02-17 13:34:02 +01:00
commit e593453abd
99 changed files with 10867 additions and 0 deletions
+283
View File
@@ -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 ""
}
}
}