Stabilize planner UI, fix reset flow, and force Premium on TestFlight
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user