Stabilize planner UI, fix reset flow, and force Premium on TestFlight

This commit is contained in:
alexandrev-tibco
2026-02-27 13:09:58 +01:00
parent ae40ec6f09
commit a532afbee6
8 changed files with 327 additions and 89 deletions
+13
View File
@@ -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<AppSettings>()
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
+16
View File
@@ -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]
}
}
+27 -1
View File
@@ -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<SlotKey>()
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))
+29 -12
View File
@@ -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<AppSettings>()
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
}
}
+37 -7
View File
@@ -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<WeekPlan>(
predicate: #Predicate<WeekPlan> { 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() {
+133 -14
View File
@@ -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 }
@@ -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) {
@@ -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)
}
}