diff --git a/MealMood/ContentView.swift b/MealMood/ContentView.swift index 2684c40..5258994 100644 --- a/MealMood/ContentView.swift +++ b/MealMood/ContentView.swift @@ -8,6 +8,10 @@ struct ContentView: View { @State private var isReady = false private var settings: AppSettings? { allSettings.first } + private var isTestFlightBuild: Bool { + guard let receiptURL = Bundle.main.appStoreReceiptURL else { return false } + return receiptURL.lastPathComponent == "sandboxReceipt" + } var body: some View { Group { @@ -72,6 +76,15 @@ struct ContentView: View { private func syncPremiumStatus() async { let descriptor = FetchDescriptor() guard let settings = try? context.fetch(descriptor).first else { return } + + if isTestFlightBuild { + if settings.isPremium != true { + settings.isPremium = true + try? context.save() + } + return + } + let premium = await StoreManager.hasActiveSubscription() if settings.isPremium != premium { settings.isPremium = premium diff --git a/MealMood/Models/MealSlot.swift b/MealMood/Models/MealSlot.swift index d25115a..40c7791 100644 --- a/MealMood/Models/MealSlot.swift +++ b/MealMood/Models/MealSlot.swift @@ -33,3 +33,19 @@ final class MealSlot { set { mealType = newValue.rawValue } } } + +extension MealSlot { + static func preferredForDuplicateResolution(_ slots: [MealSlot]) -> MealSlot { + slots.sorted { lhs, rhs in + let lhsHasDish = lhs.dishId != nil + let rhsHasDish = rhs.dishId != nil + if lhsHasDish != rhsHasDish { return lhsHasDish } + + let lhsHasEvent = lhs.calendarEventId != nil + let rhsHasEvent = rhs.calendarEventId != nil + if lhsHasEvent != rhsHasEvent { return lhsHasEvent } + + return lhs.id.uuidString < rhs.id.uuidString + }.first ?? slots[0] + } +} diff --git a/MealMood/ViewModels/HomeViewModel.swift b/MealMood/ViewModels/HomeViewModel.swift index 55400fd..c33315b 100644 --- a/MealMood/ViewModels/HomeViewModel.swift +++ b/MealMood/ViewModels/HomeViewModel.swift @@ -429,6 +429,33 @@ final class HomeViewModel: ObservableObject { } }() + // Clean up any legacy duplicates for the same day+mealType key to keep UI mapping stable. + var groupedByKey: [SlotKey: [MealSlot]] = [:] + for slot in plan.slots { + let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType) + groupedByKey[key, default: []].append(slot) + } + + var changed = false + for (_, duplicates) in groupedByKey where duplicates.count > 1 { + let keeper = MealSlot.preferredForDuplicateResolution(duplicates) + for duplicate in duplicates where duplicate.id != keeper.id { + if keeper.dishId == nil, let dishId = duplicate.dishId { + keeper.dishId = dishId + keeper.isRuleOverridden = duplicate.isRuleOverridden + if keeper.calendarEventId == nil { + keeper.calendarEventId = duplicate.calendarEventId + } + } else if let duplicateEventId = duplicate.calendarEventId { + CalendarService.shared.deleteEvent(eventId: duplicateEventId) + } + + plan.slots.removeAll { $0.id == duplicate.id } + context.delete(duplicate) + changed = true + } + } + var desired = Set() for day in 0...maxDay { for mealType in mealTypes { @@ -443,7 +470,6 @@ final class HomeViewModel: ObservableObject { existingByKey[key] = slot } } - var changed = false let toDelete = plan.slots.filter { slot in !desired.contains(SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)) diff --git a/MealMood/ViewModels/SettingsViewModel.swift b/MealMood/ViewModels/SettingsViewModel.swift index 32f31ed..2b372e8 100644 --- a/MealMood/ViewModels/SettingsViewModel.swift +++ b/MealMood/ViewModels/SettingsViewModel.swift @@ -68,26 +68,43 @@ final class SettingsViewModel: ObservableObject { func resetAllData(context: ModelContext) { do { - // WeekPlan cascades MealSlot, so deleting both can corrupt the reset flow. - try deleteAll(of: WeekPlan.self, in: context) - try deleteAll(of: Dish.self, in: context) - try deleteAll(of: Tag.self, in: context) - DefaultDataService.createDefaultTags(context: context, saveImmediately: false) - let settingsDescriptor = FetchDescriptor() + let targetSettings: AppSettings + if let existingSettings = try context.fetch(settingsDescriptor).first { - resetSettings(existingSettings) + targetSettings = existingSettings } else { - DefaultDataService.createDefaultSettings(context: context) - if let createdSettings = try context.fetch(settingsDescriptor).first { - resetSettings(createdSettings) - } + let created = AppSettings() + context.insert(created) + targetSettings = created } + // Phase 1: switch app flow out of Home before destructive deletes. + resetSettings(targetSettings) try context.save() + + Task { @MainActor in + do { + try? await Task.sleep(nanoseconds: 200_000_000) + + try deleteAll(of: WeekPlan.self, in: context) + try deleteAll(of: Dish.self, in: context) + try deleteAll(of: Tag.self, in: context) + DefaultDataService.createDefaultTags(context: context, saveImmediately: false) + + // Ensure a true "fresh launch" onboarding flow. + UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoAssignPromptKey) + UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey) + try context.save() + } catch { + #if DEBUG + print("Failed to complete reset all data: \(error)") + #endif + } + } } catch { #if DEBUG - print("Failed to reset all data: \(error)") + print("Failed to start reset all data: \(error)") #endif } } diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index e88cbaf..ac5ec50 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -33,7 +33,7 @@ struct HomeView: View { Color.mealMoodBackground.ignoresSafeArea() if let settings = settings { - let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) + let plan = fetchWeekPlan(for: viewModel.currentWeekStart) if let plan { mainContent(plan: plan, settings: settings) } @@ -109,7 +109,7 @@ struct HomeView: View { ToolbarItem(placement: .navigationBarTrailing) { HStack(spacing: 10) { if let settings = settings, - let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) { + let plan = fetchWeekPlan(for: viewModel.currentWeekStart) { if viewModel.canEditCurrentWeek { Button { viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings) @@ -161,22 +161,22 @@ struct HomeView: View { } .onAppear { guard let settings = settings, - let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) else { return } + let plan = ensureCurrentWeekPlanExists(settings: settings) else { return } viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context) } .onChange(of: settings?.includeWeekends) { _, _ in guard let settings = settings, - let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) else { return } + let plan = ensureCurrentWeekPlanExists(settings: settings) else { return } viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context) } .onChange(of: settings?.mealWindows) { _, _ in guard let settings = settings, - let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) else { return } + let plan = ensureCurrentWeekPlanExists(settings: settings) else { return } viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context) } .onChange(of: viewModel.currentWeekStart) { _, _ in guard let settings = settings, - let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) else { return } + let plan = ensureCurrentWeekPlanExists(settings: settings) else { return } viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context) } } @@ -655,12 +655,42 @@ struct HomeView: View { } private func fetchWeekPlan(for weekStartDate: Date) -> WeekPlan? { + let matches = weekPlanCandidates(for: weekStartDate) + guard !matches.isEmpty else { return nil } + return preferredWeekPlan(from: matches) + } + + private func weekPlanCandidates(for weekStartDate: Date) -> [WeekPlan] { let descriptor = FetchDescriptor( predicate: #Predicate { plan in plan.weekStartDate == weekStartDate } ) - return try? context.fetch(descriptor).first + return (try? context.fetch(descriptor)) ?? [] + } + + private func preferredWeekPlan(from plans: [WeekPlan]) -> WeekPlan? { + plans.max { lhs, rhs in + let lhsAssigned = lhs.slots.filter { $0.dishId != nil }.count + let rhsAssigned = rhs.slots.filter { $0.dishId != nil }.count + if lhsAssigned != rhsAssigned { return lhsAssigned < rhsAssigned } + if lhs.slots.count != rhs.slots.count { return lhs.slots.count < rhs.slots.count } + return lhs.updatedAt < rhs.updatedAt + } + } + + private func ensureCurrentWeekPlanExists(settings: AppSettings) -> WeekPlan? { + let matches = weekPlanCandidates(for: viewModel.currentWeekStart) + if let preferred = preferredWeekPlan(from: matches) { + if matches.count > 1 { + for duplicate in matches where duplicate.id != preferred.id { + context.delete(duplicate) + } + try? context.save() + } + return preferred + } + return DefaultDataService.createWeekPlan(for: viewModel.currentWeekStart, settings: settings, context: context) } private func evaluateReviewPrompt() { diff --git a/MealMood/Views/Home/WeekCalendarView.swift b/MealMood/Views/Home/WeekCalendarView.swift index 8619508..d20fa8c 100644 --- a/MealMood/Views/Home/WeekCalendarView.swift +++ b/MealMood/Views/Home/WeekCalendarView.swift @@ -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 } diff --git a/MealMood/Views/Onboarding/FirstDishesStepView.swift b/MealMood/Views/Onboarding/FirstDishesStepView.swift index 7116ebc..e9fa3e6 100644 --- a/MealMood/Views/Onboarding/FirstDishesStepView.swift +++ b/MealMood/Views/Onboarding/FirstDishesStepView.swift @@ -30,34 +30,38 @@ struct FirstDishesStepView: View { } private let maxVisibleSuggestions = 3 + private var addedDishesReversed: [(index: Int, dish: (name: String, tagIds: [UUID]))] { + Array(viewModel.addedDishes.enumerated()).reversed().map { ($0.offset, $0.element) } + } var body: some View { ScrollView { - VStack(spacing: 24) { - Text("first_dishes_title") - .font(.mealMoodH1) - .foregroundColor(.mealMoodTextPrimary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24) - .padding(.top, 12) + VStack(spacing: 0) { + VStack(spacing: 24) { + Text("first_dishes_title") + .font(.mealMoodH1) + .foregroundColor(.mealMoodTextPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.top, 12) - Text("first_dishes_subtitle") - .font(.mealMoodSmall) - .foregroundColor(.mealMoodTextSecondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24) + Text("first_dishes_subtitle") + .font(.mealMoodSmall) + .foregroundColor(.mealMoodTextSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) - // New dish form - VStack(spacing: 12) { - TextField("first_dishes_name_placeholder", text: $viewModel.newDishName) - .font(.mealMoodBody) - .padding(14) - .background(Color.mealMoodSurface) - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color(hex: "#F0F0F0"), lineWidth: 1) - ) + // New dish form + VStack(spacing: 12) { + TextField("first_dishes_name_placeholder", text: $viewModel.newDishName) + .font(.mealMoodBody) + .padding(14) + .background(Color.mealMoodSurface) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(hex: "#F0F0F0"), lineWidth: 1) + ) // Selected tags if !viewModel.newDishTags.isEmpty { @@ -117,26 +121,28 @@ struct FirstDishesStepView: View { } .disabled(!viewModel.canAddDish) } - .padding(.horizontal, 24) + .padding(.horizontal, 24) - // Added dishes list - if !viewModel.addedDishes.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("first_dishes_added") - .font(.mealMoodSmall) - .foregroundColor(.mealMoodTextSecondary) - .padding(.horizontal, 24) + // Added dishes list + if !viewModel.addedDishes.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("first_dishes_added") + .font(.mealMoodSmall) + .foregroundColor(.mealMoodTextSecondary) + .padding(.horizontal, 24) - ScrollView { - LazyVStack(spacing: 8) { - ForEach(Array(viewModel.addedDishes.enumerated()), id: \.offset) { index, dish in - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.mealMoodSuccess) + ScrollView { + LazyVStack(spacing: 8) { + ForEach(addedDishesReversed, id: \.index) { item in + let index = item.index + let dish = item.dish + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.mealMoodSuccess) - Text(dish.name) - .font(.mealMoodBody) - .foregroundColor(.mealMoodTextPrimary) + Text(dish.name) + .font(.mealMoodBody) + .foregroundColor(.mealMoodTextPrimary) // Show tag pills HStack(spacing: 4) { @@ -145,26 +151,26 @@ struct FirstDishesStepView: View { } } - Spacer() + Spacer() - Button { - viewModel.removeDish(at: index) - } label: { - Image(systemName: "trash") - .font(.system(size: 14)) - .foregroundColor(.mealMoodError) + Button { + viewModel.removeDish(at: index) + } label: { + Image(systemName: "trash") + .font(.system(size: 14)) + .foregroundColor(.mealMoodError) + } } + .padding(12) + .background(Color.mealMoodSurface) + .cornerRadius(12) } - .padding(12) - .background(Color.mealMoodSurface) - .cornerRadius(12) } } + .frame(maxHeight: 190) + .padding(.horizontal, 24) } - .frame(maxHeight: 190) - .padding(.horizontal, 24) } - } VStack(alignment: .leading, spacing: 10) { Text("first_dishes_suggestions") @@ -214,7 +220,9 @@ struct FirstDishesStepView: View { } } - Spacer(minLength: 40) + Spacer(minLength: 20) + } + .frame(maxWidth: .infinity, alignment: .top) PrimaryButton( title: String(localized: "onboarding_finish"), @@ -227,7 +235,8 @@ struct FirstDishesStepView: View { isEnabled: viewModel.addedDishes.count >= 2 ) .padding(.horizontal, 24) - .padding(.bottom, 40) + .padding(.top, 12) + .padding(.bottom, 24) } } .sheet(isPresented: $showTagSelector) { diff --git a/MealMoodTests/AutocompleteEngineTests.swift b/MealMoodTests/AutocompleteEngineTests.swift index 1cb7cb6..d75d09c 100644 --- a/MealMoodTests/AutocompleteEngineTests.swift +++ b/MealMoodTests/AutocompleteEngineTests.swift @@ -72,4 +72,12 @@ final class AutocompleteEngineTests: XCTestCase { let afterDeletion = Dish.stableSortedForDisplay([newest, oldest]).map(\.id) XCTAssertEqual(afterDeletion, [newest.id, oldest.id]) } + + func testPreferredDuplicateSlotKeepsAssignedDish() { + let duplicateWithDish = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: UUID()) + let duplicateWithoutDish = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: nil) + + let preferred = MealSlot.preferredForDuplicateResolution([duplicateWithoutDish, duplicateWithDish]) + XCTAssertEqual(preferred.id, duplicateWithDish.id) + } }