Stabilize planner UI, fix reset flow, and force Premium on TestFlight
This commit is contained in:
@@ -2,6 +2,8 @@ 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
|
||||
@@ -34,6 +36,23 @@ struct WeekCalendarView: View {
|
||||
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 {
|
||||
@@ -55,7 +74,7 @@ struct WeekCalendarView: View {
|
||||
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)
|
||||
let slot = displayedSlot(day: day, mealType: mealType)
|
||||
draggableSlotView(slot: slot, mealType: mealType)
|
||||
.frame(width: 96)
|
||||
if index < days.count - 1 {
|
||||
@@ -111,7 +130,7 @@ struct WeekCalendarView: View {
|
||||
.padding(.trailing, spacing)
|
||||
|
||||
ForEach(Array(days.enumerated()), id: \.element) { index, day in
|
||||
let slot = slotFor(day: day, mealType: mealType)
|
||||
let slot = displayedSlot(day: day, mealType: mealType)
|
||||
draggableSlotView(slot: slot, mealType: mealType)
|
||||
.frame(width: dayWidth, height: slotRowHeight)
|
||||
if index < days.count - 1 {
|
||||
@@ -186,16 +205,30 @@ struct WeekCalendarView: View {
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
private func slotFor(day: Int, mealType: MealType) -> MealSlot? {
|
||||
plan.slots.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue }
|
||||
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: MealSlot?, mealType: MealType) -> some View {
|
||||
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 targetSlot = slot,
|
||||
let targetDisplaySlot = slot,
|
||||
let targetSlot = liveSlot(for: targetDisplaySlot),
|
||||
viewModel.canEditCurrentWeek else { return false }
|
||||
|
||||
switch payload {
|
||||
@@ -230,27 +263,29 @@ struct WeekCalendarView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func slotView(slot: MealSlot?, mealType: MealType) -> some View {
|
||||
private func slotView(slot: DisplaySlot?, 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) }
|
||||
let dishData = dishData(for: dishId) {
|
||||
let dishTags = tags.filter { dishData.tagIds.contains($0.id) }
|
||||
FilledSlotView(
|
||||
dishName: dish.name,
|
||||
dishDescription: dish.descriptionText,
|
||||
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 ? {
|
||||
viewModel.removeDish(from: slot, plan: plan, settings: settings)
|
||||
guard let live = liveSlot(for: slot) else { return }
|
||||
viewModel.removeDish(from: live, plan: plan, settings: settings)
|
||||
} : nil
|
||||
)
|
||||
.draggable("slot:\(slot.id.uuidString)")
|
||||
.draggable("slot:\(slot.slotId.uuidString)")
|
||||
} else if let slot = slot {
|
||||
EmptySlotView(mealType: mealType)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard viewModel.canEditCurrentWeek else { return }
|
||||
onTapEmptySlot?(slot)
|
||||
guard let live = liveSlot(for: slot) else { return }
|
||||
onTapEmptySlot?(live)
|
||||
}
|
||||
} else {
|
||||
EmptySlotView(mealType: mealType)
|
||||
@@ -262,6 +297,90 @@ struct WeekCalendarView: View {
|
||||
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 }
|
||||
|
||||
Reference in New Issue
Block a user