Files
FamilyMealPlanner/MealMood/Views/Home/HomeView.swift
T
alexandrev-tibco a049e4f74b watch: el dia de hoy se resuelve al pintar, no al generar el envio
El iPhone marcaba isToday al construir el snapshot y el reloj se creia esa
marca para siempre. Como el snapshot sobrevive en el app group, un sabado
seguia enseñando la cena del miercoles: el dia que era cuando se genero.

Ahora cada dia viaja con su fecha real y el reloj busca hoy cuando dibuja. Si
el snapshot es de otra semana no hay "hoy" que enseñar, asi que en vez de
colar la comida de otro dia lo dice y pide abrir el iPhone. Para snapshots
guardados por versiones anteriores, que no traen fecha, solo se confia en el
indice del dia mientras el propio snapshot sea de esta semana.

Ademas el reloj pide datos al abrirse y el iPhone, en vez de responder con lo
ultimo que cacheo, reconstruye la semana desde el store — que es la otra mitad
del "no se actualiza": el cache podia ser de hace dias.

Y cuando no hay nada planificado se dice, que antes era un guion indistinguible
de una comida marcada como no planificada: "Hoy no hay nada planificado" para
el dia entero y "Sin planificar" por comida, en los 6 idiomas (el bundle del
reloj no tiene cadenas propias, viajan en el envio).

Refs #34, #35

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
2026-09-12 13:21:21 +02:00

1937 lines
85 KiB
Swift

import SwiftUI
import SwiftData
import UIKit
struct HomeView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.modelContext) private var context
@Environment(\.scenePhase) private var scenePhase
@Environment(\.openURL) private var openURL
@Query(sort: \Dish.createdAt, order: .reverse) private var dishes: [Dish]
@Query private var tags: [Tag]
@Query(sort: \WeekPlan.weekStartDate, order: .forward) private var weekPlans: [WeekPlan]
@Query private var allSettings: [AppSettings]
@StateObject private var viewModel = HomeViewModel()
@State private var editingDish: Dish?
// sheet(item:) so the source travels with the presentation with two
// separate @State vars the sheet sometimes rendered before the source was
// set and logged paywall_viewed as "unknown".
@State private var paywallPresentation: PaywallPresentation?
@State private var showDishLimitModal: Bool = false
@State private var showMonthlyHistory: Bool = false
@State private var showStats: Bool = false
@State private var showWeekLimitUpsell: Bool = false
@State private var wasWeekComplete: Bool = false
@State private var selectedEmptySlotId: UUID?
@State private var selectedFilledSlotId: UUID?
@State private var showWeekPicker: Bool = false
@State private var showWeekSchedule: Bool = false
@State private var weekPickerDate: Date = Date()
@State private var showCopyPreviousConfirm: Bool = false
@State private var copyPreviousSource: String = "menu"
@State private var showShoppingList: Bool = false
@State private var showReviewSentimentPrompt: Bool = false
@State private var showReviewSupportPrompt: Bool = false
@State private var showPostOnboardingAutoAssignPrompt: Bool = false
@State private var showPostOnboardingPremiumPrompt: Bool = false
@State private var hasEvaluatedPostOnboardingPrompts: Bool = false
@State private var showWidgetPromo: Bool = false
private static let widgetPromoDismissedKey = "widget_promo_dismissed"
private static let homeSessionCountKey = "home_session_count"
@State private var exportPreviewPlan: WeekPlan?
@State private var showViolationsPanel: Bool = false
private var settings: AppSettings? { allSettings.first }
private var isRunningOnMac: Bool { ProcessInfo.processInfo.isiOSAppOnMac }
private var contentHorizontalPadding: CGFloat { (isRunningOnMac || horizontalSizeClass == .regular) ? 14 : 0 }
/// User-adjustable split (iPad/Mac) between the calendar and the dish
/// drawer, dragged via the resize handle. 0.5 = half each.
@AppStorage("calendarHeightFraction") private var calendarFraction: Double = 0.5
@State private var dragStartFraction: Double?
/// Weeks whose export banner the user has dismissed (comma-joined week keys).
@AppStorage("dismissedExportWeeks") private var dismissedExportWeeksRaw: String = ""
private func exportWeekKey(_ plan: WeekPlan) -> String {
String(Int(plan.weekStartDate.timeIntervalSince1970))
}
private func isExportCalloutDismissed(_ plan: WeekPlan) -> Bool {
dismissedExportWeeksRaw.split(separator: ",").contains(Substring(exportWeekKey(plan)))
}
private func dismissExportCallout(_ plan: WeekPlan) {
var keys = Set(dismissedExportWeeksRaw.split(separator: ",").map(String.init))
keys.insert(exportWeekKey(plan))
// Cap the stored history so it can't grow unbounded.
dismissedExportWeeksRaw = keys.sorted().suffix(24).joined(separator: ",")
}
private static let minCalendarFraction: Double = 0.28
private static let maxCalendarFraction: Double = 0.72
/// On iPad/Mac (regular width) the calendar takes `calendarFraction` of the
/// available height (draggable), keeping a floor for the dish drawer. On
/// iPhone (compact) return nil to keep the intrinsic sizing.
private func calendarHeight(availableHeight: CGFloat) -> CGFloat? {
guard horizontalSizeClass == .regular, availableHeight > 0 else { return nil }
let target = availableHeight * calendarFraction
// Keep a sensible floor for both the calendar and the dish drawer below.
return min(max(target, 200), availableHeight - 200)
}
/// Draggable handle (iPad/Mac only) to rebalance calendar vs. dishes.
private func calendarResizeHandle(availableHeight: CGFloat) -> some View {
VStack(spacing: 3) {
RoundedRectangle(cornerRadius: 2.5)
.fill(Color.mealMoodTextSecondary.opacity(0.35))
.frame(width: 40, height: 5)
}
.frame(maxWidth: .infinity)
.frame(height: 20)
.contentShape(Rectangle())
.gesture(
DragGesture()
.onChanged { value in
guard availableHeight > 0 else { return }
let base = dragStartFraction ?? calendarFraction
if dragStartFraction == nil { dragStartFraction = base }
let delta = value.translation.height / availableHeight
calendarFraction = min(Self.maxCalendarFraction,
max(Self.minCalendarFraction, base + delta))
}
.onEnded { _ in dragStartFraction = nil }
)
.accessibilityLabel(Text("calendar_resize_hint"))
}
var body: some View {
NavigationStack {
ZStack {
Color.mealMoodBackground.ignoresSafeArea()
if let settings = settings {
let plan = fetchWeekPlan(for: viewModel.currentWeekStart)
if let plan {
mainContent(plan: plan, settings: settings)
}
}
}
.accessibilityIdentifier("home_root")
.toast(isShowing: $viewModel.showToast, message: viewModel.toastMessage)
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(Color.mealMoodCoral.opacity(0.22), for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
HStack(spacing: 12) {
NavigationLink(destination: SettingsView()) {
Image(systemName: "gearshape")
.foregroundColor(.mealMoodTextPrimary)
}
Button {
showShoppingList = true
} label: {
Image(systemName: "cart")
.foregroundColor(.mealMoodTextPrimary)
}
.accessibilityLabel(Text("shopping_list_title"))
if settings?.isPremium == true {
Button {
showStats = true
} label: {
Image(systemName: "chart.bar.xaxis")
.foregroundColor(.mealMoodTextPrimary)
}
Button {
showMonthlyHistory = true
} label: {
Image(systemName: "calendar")
.foregroundColor(.mealMoodTextPrimary)
}
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 10) {
if let settings = settings,
let plan = fetchWeekPlan(for: viewModel.currentWeekStart) {
if plan.slotList.contains(where: { $0.dishId != nil || $0.isEatingOut || $0.isSkipped }) {
Button {
requestWeekShare(plan: plan, settings: settings)
} label: {
Image(systemName: "square.and.arrow.up")
.foregroundColor(.mealMoodTextPrimary)
}
.accessibilityLabel(Text("share_week_button"))
}
if viewModel.canEditCurrentWeek {
Button {
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings, allPlans: weekPlans)
} label: {
Image(systemName: "wand.and.stars")
.foregroundColor(.mealMoodTextPrimary)
}
.contextMenu {
Button {
viewModel.undoLastAction(plan: plan, settings: settings)
} label: {
Label("home_undo_last_action", systemImage: "arrow.uturn.backward")
}
.disabled(!viewModel.canUndo(for: plan))
Button {
requestCopyPreviousWeek(plan: plan, settings: settings, source: "menu")
} label: {
Label("home_copy_previous_week", systemImage: "doc.on.doc")
}
let violationCount = AutocompleteEngine.findViolations(plan: plan, allDishes: dishes, allTags: tags).count
if violationCount > 0 {
Button {
showViolationsPanel = true
} label: {
Label(
String(format: String(localized: "violations_badge"), violationCount),
systemImage: "exclamationmark.triangle.fill"
)
}
}
Button(role: .destructive) {
viewModel.showResetAlert = true
} label: {
Label("home_reset", systemImage: "arrow.counterclockwise")
}
}
.accessibilityLabel(Text("home_complete"))
.disabled(viewModel.isAutoCompleting || dishes.isEmpty)
if settings.syncEnabled && settings.syncModeEnum == .manual {
Button {
viewModel.syncWeekToCalendar(plan: plan, dishes: dishes, settings: settings)
} label: {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundColor(.mealMoodTextPrimary)
}
.accessibilityLabel(Text("settings_sync_now"))
}
}
}
}
}
}
}
.onAppear {
AnalyticsService.logScreenView("Home")
guard let settings = settings,
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 = ensureCurrentWeekPlanExists(settings: settings) else { return }
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
}
.onChange(of: settings?.enabledMealTypesRaw) { _, _ in
guard let settings = settings,
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 = ensureCurrentWeekPlanExists(settings: settings) else { return }
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
}
.onChange(of: viewModel.currentWeekStart) { _, _ in
guard let settings = settings,
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
}
}
/// Week switcher. It used to live in the navigation bar's principal slot,
/// where a premium user's four leading buttons squeezed it until the arrows
/// were clipped away leaving no visible way to change week.
private func weekSwitcher(plan: WeekPlan, settings: AppSettings) -> some View {
HStack(spacing: 12) {
Button {
viewModel.goToPreviousWeek()
showWeekLimitUpsell = false
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 15, weight: .bold))
.foregroundColor(.mealMoodCoral)
.frame(width: 36, height: 36)
.background(Circle().fill(Color.white))
.overlay(Circle().stroke(Color.mealMoodCoral.opacity(0.35), lineWidth: 1))
}
.buttonStyle(.plain)
.accessibilityLabel(Text("home_previous"))
Button {
weekPickerDate = viewModel.currentWeekStart
showWeekPicker = true
} label: {
HStack(spacing: 5) {
Text(viewModel.weekRangeText)
.font(.mealMoodH2)
.foregroundColor(.mealMoodTextPrimary)
.lineLimit(1)
.minimumScaleFactor(0.7)
Image(systemName: "chevron.down")
.font(.system(size: 11, weight: .bold))
.foregroundColor(.mealMoodTextSecondary)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("home_select_week"))
Button {
if canNavigateToNextWeek(settings: settings) {
viewModel.goToNextWeek()
showWeekLimitUpsell = false
} else {
withAnimation(.easeInOut(duration: 0.2)) {
showWeekLimitUpsell = true
}
AnalyticsService.logWeekLimitHit()
paywallPresentation = PaywallPresentation(source: "week_limit")
HapticManager.shared.notification(type: .warning)
}
} label: {
Image(systemName: "chevron.right")
.font(.system(size: 15, weight: .bold))
.foregroundColor(.mealMoodCoral)
.frame(width: 36, height: 36)
.background(Circle().fill(Color.white))
.overlay(Circle().stroke(Color.mealMoodCoral.opacity(0.35), lineWidth: 1))
}
.buttonStyle(.plain)
.accessibilityLabel(Text("home_next"))
// Per-week schedule (premium): weekend on/off and which meals, for
// this week only. Free users get the sheet with the upsell inside.
if viewModel.canEditCurrentWeek {
Button {
showWeekSchedule = true
AnalyticsService.logEvent("week_schedule_opened", parameters: [
"is_premium": settings.isPremium
])
} label: {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.mealMoodCoral)
.frame(width: 36, height: 36)
.background(Circle().fill(Color.white))
.overlay(Circle().stroke(Color.mealMoodCoral.opacity(0.35), lineWidth: 1))
.overlay(alignment: .topTrailing) {
if plan.hasScheduleOverride {
Circle()
.fill(Color.mealMoodCoral)
.frame(width: 9, height: 9)
.overlay(Circle().stroke(Color.mealMoodBackground, lineWidth: 1.5))
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(Text("week_schedule_title"))
}
}
.padding(.horizontal, 16)
.padding(.top, 6)
.padding(.bottom, 10)
}
/// Banner shown on the home whenever the week breaks rules. The panel used
/// to be reachable only from the "···" menu, so violations went unnoticed.
@ViewBuilder
private func violationsBanner(plan: WeekPlan) -> some View {
let violations = AutocompleteEngine.findViolations(plan: plan, allDishes: dishes, allTags: tags)
if !violations.isEmpty {
Button {
showViolationsPanel = true
} label: {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 16))
.foregroundColor(.mealMoodWarning)
VStack(alignment: .leading, spacing: 2) {
Text(String(format: String(localized: "violations_banner_title"), violations.count))
.font(.mealMoodSmall.weight(.semibold))
.foregroundColor(.mealMoodTextPrimary)
Text(violationsSummary(violations))
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
.lineLimit(1)
}
Spacer()
Text("violations_banner_cta")
.font(.mealMoodCaption.weight(.semibold))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.mealMoodWarning)
.clipShape(Capsule())
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color.mealMoodWarning.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.mealMoodWarning.opacity(0.45), lineWidth: 1)
)
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
}
/// "Lun · Cena, Mié · Comida" where the problems are, at a glance.
private func violationsSummary(_ violations: [AutocompleteEngine.RuleViolation]) -> String {
let language = settings?.languageEnum.resolved() ?? .spanish
let keys = ["day_mon","day_tue","day_wed","day_thu","day_fri","day_sat","day_sun"]
return violations.prefix(3).map { violation in
let day = violation.dayOfWeek >= 0 && violation.dayOfWeek < keys.count
? localizedString(keys[violation.dayOfWeek], language: language) : ""
return "\(day) · \(localizedString(violation.mealType, language: language))"
}.joined(separator: ", ")
}
@ViewBuilder
private func mainContent(plan: WeekPlan, settings: AppSettings) -> some View {
VStack(spacing: 0) {
weekSwitcher(plan: plan, settings: settings)
violationsBanner(plan: plan)
if showWeekLimitUpsell && !settings.isPremium {
PremiumUpsellBanner(
messageKey: "premium_limit_future_weeks",
actionTitleKey: "premium_subscribe"
) {
paywallPresentation = PaywallPresentation(source: "week_limit_banner")
}
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
if showWidgetPromo {
widgetPromoCard
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
GeometryReader { contentGeo in
VStack(spacing: 10) {
WeekCalendarView(
plan: plan,
settings: settings,
dishes: dishes,
tags: tags,
viewModel: viewModel,
onTapEmptySlot: { slot in
selectedEmptySlotId = slot.id
},
onTapFilledSlot: { slot in
selectedFilledSlotId = slot.id
}
)
.frame(height: calendarHeight(availableHeight: contentGeo.size.height))
if horizontalSizeClass == .regular {
calendarResizeHandle(availableHeight: contentGeo.size.height)
}
let filledCount = plan.slotList.filter { $0.dishId != nil || $0.isEatingOut || $0.isSkipped }.count
let emptyCount = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut && !$0.isSkipped }.count
// Only surface the export banner once the week is actually
// complete (no empty slots) and let the user dismiss it.
if filledCount > 0 && emptyCount == 0 && !isExportCalloutDismissed(plan) {
exportCallout(plan: plan, settings: settings)
}
if !viewModel.canEditCurrentWeek && filledCount > 0 && settings.isPremium {
weekRatingRow(plan: plan)
}
if viewModel.canEditCurrentWeek && !dishes.isEmpty && emptyCount > 0 {
autoAssignBanner(emptyCount: emptyCount, plan: plan, settings: settings)
}
ScrollView(showsIndicators: true) {
dishDrawer(plan: plan, settings: settings)
.padding(.bottom, 0)
}
.frame(maxHeight: .infinity)
}
.frame(maxWidth: .infinity, minHeight: contentGeo.size.height, alignment: .top)
.padding(.horizontal, contentHorizontalPadding)
.padding(.bottom, 0)
}
}
.frame(maxHeight: .infinity, alignment: .top)
.safeAreaInset(edge: .bottom, spacing: 0) {
if !settings.isPremium && !isRunningOnMac {
BottomPromoBanner(settings: settings)
}
}
.alert("reset_title", isPresented: $viewModel.showResetAlert) {
Button(String(localized: "reset_cancel"), role: .cancel) {}
Button(String(localized: "reset_confirm"), role: .destructive) {
viewModel.resetWeek(plan: plan, settings: settings)
}
} message: {
Text("reset_message")
}
.alert("copy_previous_confirm_title", isPresented: $showCopyPreviousConfirm) {
Button("reset_cancel", role: .cancel) {}
Button("copy_previous_confirm_confirm", role: .destructive) {
copyFromPreviousWeek(currentPlan: plan, settings: settings)
}
} message: {
Text("copy_previous_confirm_message")
}
.alert(item: $viewModel.invalidDropContext) { context in
Alert(
title: Text("rule_override_title"),
message: Text("rule_override_message"),
primaryButton: .destructive(Text("rule_override_confirm")) {
viewModel.assignDish(context.dish, to: context.slot, plan: plan, settings: settings, isOverride: true)
},
secondaryButton: .cancel(Text("reset_cancel"))
)
}
.alert("onboarding_auto_assign_title", isPresented: $showPostOnboardingAutoAssignPrompt) {
Button("onboarding_auto_assign_yes") {
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings, allPlans: weekPlans)
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
schedulePostOnboardingPremiumPromptIfNeeded(settings: settings)
}
Button("onboarding_auto_assign_no", role: .cancel) {
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
schedulePostOnboardingPremiumPromptIfNeeded(settings: settings)
}
} message: {
Text("onboarding_auto_assign_message")
}
.alert("onboarding_premium_prompt_title", isPresented: $showPostOnboardingPremiumPrompt) {
Button("onboarding_premium_prompt_cta") {
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey)
paywallPresentation = PaywallPresentation(source: "post_onboarding")
}
Button("reset_cancel", role: .cancel) {
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey)
}
} message: {
Text("onboarding_premium_prompt_message")
}
.alert("review_funnel_title", isPresented: $showReviewSentimentPrompt) {
Button("review_funnel_positive") {
AnalyticsService.logEvent("review_funnel_positive")
ReviewPromptService.shared.requestReview()
ReviewPromptService.shared.markReviewCompleted()
}
Button("review_funnel_negative", role: .destructive) {
AnalyticsService.logEvent("review_funnel_negative")
showReviewSupportPrompt = true
}
Button("reset_cancel", role: .cancel) {
AnalyticsService.logEvent("review_funnel_dismissed")
}
} message: {
Text("review_funnel_message")
}
.alert("review_feedback_title", isPresented: $showReviewSupportPrompt) {
Button("review_feedback_contact") {
if let url = URL(string: "mailto:support@mealmood.app?subject=MealMood%20Feedback") {
openURL(url)
}
}
Button("reset_cancel", role: .cancel) {}
} message: {
Text("review_feedback_message")
}
.sheet(isPresented: $viewModel.showDishForm) {
DishFormView()
}
.sheet(isPresented: $showDishLimitModal) {
DishLimitModal(
onSeePremium: {
showDishLimitModal = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
paywallPresentation = PaywallPresentation(source: "dish_limit")
}
},
onDismiss: { showDishLimitModal = false }
)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showWeekPicker) {
NavigationStack {
Form {
DatePicker(
"home_week_picker_date",
selection: $weekPickerDate,
displayedComponents: .date
)
.datePickerStyle(.graphical)
}
.scrollContentBackground(.hidden)
.background(Color.mealMoodBackground)
.navigationTitle("home_week_picker_title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("reset_cancel") {
showWeekPicker = false
}
}
ToolbarItem(placement: .confirmationAction) {
Button("home_week_picker_go") {
jumpToSelectedWeek()
showWeekPicker = false
}
}
}
}
.presentationDetents([.medium, .large])
}
.sheet(isPresented: $showWeekSchedule) {
WeekScheduleSheet(
plan: plan,
settings: settings,
onApply: { includeWeekends, mealTypes in
viewModel.applyWeekSchedule(
includeWeekends: includeWeekends,
mealTypes: mealTypes,
plan: plan,
settings: settings,
context: context
)
},
onReset: {
viewModel.resetWeekSchedule(plan: plan, settings: settings, context: context)
},
onUpsell: {
showWeekSchedule = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
paywallPresentation = PaywallPresentation(source: "week_schedule")
}
}
)
}
.sheet(
isPresented: Binding(
get: { selectedEmptySlotId != nil },
set: { isPresented in
if !isPresented { selectedEmptySlotId = nil }
}
)
) {
if let slotId = selectedEmptySlotId,
plan.slotList.contains(where: { $0.id == slotId }) {
SlotDishPickerSheet(
dishes: dishes,
tags: tags,
onPickDish: { dish in
guard let freshSlot = plan.slotList.first(where: { $0.id == slotId }) else {
selectedEmptySlotId = nil
return
}
let isValid = AutocompleteEngine.validateDrop(
dish: dish,
slot: freshSlot,
plan: plan,
allTags: tags,
allDishes: dishes
)
if isValid {
viewModel.assignDish(dish, to: freshSlot, plan: plan, settings: settings)
} else {
viewModel.assignDish(dish, to: freshSlot, plan: plan, settings: settings, isOverride: true)
}
selectedEmptySlotId = nil
},
onCreateDish: {
selectedEmptySlotId = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
attemptCreateDish()
}
},
onEatingOut: {
guard let freshSlot = plan.slotList.first(where: { $0.id == slotId }) else {
selectedEmptySlotId = nil
return
}
selectedEmptySlotId = nil
viewModel.markEatingOut(slot: freshSlot, plan: plan, settings: settings)
},
onSkip: {
guard let freshSlot = plan.slotList.first(where: { $0.id == slotId }) else {
selectedEmptySlotId = nil
return
}
selectedEmptySlotId = nil
viewModel.markSkipped(slot: freshSlot, plan: plan, settings: settings)
}
)
}
}
.sheet(
isPresented: Binding(
get: { selectedFilledSlotId != nil },
set: { isPresented in
if !isPresented { selectedFilledSlotId = nil }
}
)
) {
if let slotId = selectedFilledSlotId,
plan.slotList.contains(where: { $0.id == slotId }) {
SlotDishPickerSheet(
dishes: dishes,
tags: tags,
onPickDish: { dish in
guard let freshSlot = plan.slotList.first(where: { $0.id == slotId }) else {
selectedFilledSlotId = nil
return
}
viewModel.assignDish(dish, to: freshSlot, plan: plan, settings: settings, isOverride: true)
selectedFilledSlotId = nil
},
onCreateDish: {
selectedFilledSlotId = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
attemptCreateDish()
}
},
onPickSecondDish: { dish in
guard let freshSlot = plan.slotList.first(where: { $0.id == slotId }) else {
selectedFilledSlotId = nil
return
}
viewModel.assignSecondaryDish(dish, to: freshSlot, plan: plan, settings: settings)
selectedFilledSlotId = nil
},
onRemoveSecondDish: plan.slotList.first(where: { $0.id == slotId })?.secondaryDishId != nil ? {
guard let freshSlot = plan.slotList.first(where: { $0.id == slotId }) else {
selectedFilledSlotId = nil
return
}
viewModel.removeSecondaryDish(from: freshSlot, plan: plan)
selectedFilledSlotId = nil
} : nil
)
}
}
.sheet(item: $editingDish) { dish in
DishFormView(dish: dish)
}
.sheet(item: $paywallPresentation) { presentation in
NavigationStack {
PremiumView(settings: settings, source: presentation.source)
}
}
.sheet(item: $exportPreviewPlan) { exportPlan in
WeekExportPreviewSheet(plan: exportPlan, settings: settings, dishes: dishes, tags: tags)
}
.sheet(isPresented: $showViolationsPanel) {
RuleViolationsPanelSheet(
plan: plan,
dishes: dishes,
tags: tags,
settings: settings,
onFix: { slot in
viewModel.removeDish(from: slot, plan: plan, settings: settings)
},
onReplace: { slot in
// Straight to the picker for that slot so the user can swap
// the dish instead of just clearing it.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
selectedFilledSlotId = slot.id
}
},
onIgnore: { slot in
viewModel.acknowledgeViolation(slot: slot, plan: plan)
}
)
}
.sheet(isPresented: $showMonthlyHistory) {
MonthlyHistoryView(
weekPlans: weekPlans,
currentWeekStart: viewModel.currentWeekStart,
language: settings.languageEnum.resolved()
) { weekStart in
viewModel.jumpToWeek(startDate: weekStart)
showMonthlyHistory = false
}
}
.sheet(isPresented: $showStats) {
StatsView(weekPlans: weekPlans, allDishes: dishes, allTags: tags, language: settings.languageEnum.resolved())
}
.sheet(isPresented: $showShoppingList) {
ShoppingListView(
weekStartDate: viewModel.currentWeekStart,
plan: plan,
settings: settings
)
}
.task {
// Permission is now requested contextually (onboarding Week Ready step or
// the Settings toggle) no cold OS prompt on first Home load.
let nextPlan = fetchWeekPlan(for: Date().startOfWeek().addingDays(7))
NotificationService.shared.schedulePlanningReminderIfNeeded(
nextWeekPlan: nextPlan,
language: settings.languageEnum.resolved()
)
wasWeekComplete = isWeekComplete(plan: plan)
evaluatePostOnboardingPromptsIfNeeded(plan: plan, settings: settings)
evaluateWidgetPromo()
if settings.isPremium {
ReviewPromptService.shared.requestForExistingPremiumUser()
}
updateWidget(settings: settings)
}
.onOpenURL { url in
// mealmood://today widget deep-link back to the current week.
guard url.scheme == "mealmood", url.host == "today" else { return }
viewModel.jumpToWeek(startDate: Date().startOfWeek())
}
// Coming back to the app is the moment the watch is most likely to be
// holding a stale snapshot (a day or a week old).
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
updateWidget(settings: settings)
}
.onChange(of: plan.updatedAt) { _, _ in
let nowComplete = isWeekComplete(plan: plan)
if nowComplete && !wasWeekComplete {
evaluateReviewPrompt()
}
wasWeekComplete = nowComplete
updateWidget(settings: settings)
}
}
private struct SlotDishPickerSheet: View {
let dishes: [Dish]
let tags: [Tag]
let onPickDish: (Dish) -> Void
let onCreateDish: () -> Void
var onEatingOut: (() -> Void)? = nil
var onSkip: (() -> Void)? = nil
/// Set for filled slots: taps add a companion dish instead of replacing
/// when the "second dish" mode is on.
var onPickSecondDish: ((Dish) -> Void)? = nil
var onRemoveSecondDish: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var searchText: String = ""
@State private var asSecondDish: Bool = false
private var filteredDishes: [Dish] {
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return dishes.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
return dishes
.filter { $0.name.localizedCaseInsensitiveContains(trimmed) }
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
var body: some View {
NavigationStack {
Group {
if dishes.isEmpty {
VStack(spacing: 16) {
Text("home_pick_dish_no_dishes")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
Button {
dismiss()
onCreateDish()
} label: {
Label("home_pick_dish_add_new", systemImage: "plus")
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodCoral)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(24)
} else {
List {
if let onEatingOut, searchText.isEmpty {
Button {
dismiss()
onEatingOut()
} label: {
Label("home_mark_eating_out", systemImage: "fork.knife.circle")
.font(.mealMoodBody)
.foregroundColor(Color(hex: "#6B9E90"))
}
.listRowBackground(Color(hex: "#EEF8F5"))
}
if let onSkip, searchText.isEmpty {
Button {
dismiss()
onSkip()
} label: {
Label("home_mark_skipped", systemImage: "minus.circle")
.font(.mealMoodBody)
.foregroundColor(Color(hex: "#8E8B86"))
}
.listRowBackground(Color(hex: "#F5F3F0"))
}
if onPickSecondDish != nil, searchText.isEmpty {
Toggle(isOn: $asSecondDish) {
Label("home_pick_second_dish", systemImage: "plus.square.on.square")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextPrimary)
}
.tint(.mealMoodCoral)
.listRowBackground(Color.mealMoodSurface)
if let onRemoveSecondDish {
Button {
dismiss()
onRemoveSecondDish()
} label: {
Label("home_remove_second_dish", systemImage: "minus.square")
.font(.mealMoodBody)
.foregroundColor(.mealMoodError)
}
.listRowBackground(Color.mealMoodSurface)
}
}
ForEach(filteredDishes, id: \.id) { dish in
Button {
if asSecondDish, let onPickSecondDish {
onPickSecondDish(dish)
} else {
onPickDish(dish)
}
dismiss()
} label: {
VStack(alignment: .leading, spacing: 6) {
Text(dish.name)
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
HStack(spacing: 4) {
let dishTags = tags.filter { dish.tagIds.contains($0.id) }
ForEach(dishTags.prefix(3), id: \.id) { tag in
TagDot(color: tag.color, size: 8)
}
}
}
.padding(.vertical, 2)
}
.listRowBackground(Color.mealMoodSurface)
}
if filteredDishes.isEmpty {
Text("home_pick_dish_empty")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.listRowBackground(Color.mealMoodSurface)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.mealMoodBackground)
.searchable(text: $searchText, prompt: Text("home_pick_dish_search"))
}
}
.background(Color.mealMoodBackground.ignoresSafeArea())
.environment(\.colorScheme, .light)
.navigationTitle("home_pick_dish_title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("dish_cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
dismiss()
onCreateDish()
} label: {
Image(systemName: "plus")
}
.accessibilityLabel(Text("home_pick_dish_add_new"))
}
}
}
}
}
private func weekRatingRow(plan: WeekPlan) -> some View {
HStack(spacing: 12) {
Text("week_rating_prompt")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
Spacer()
Button {
viewModel.rateWeek(plan: plan, rating: plan.userRating == 1 ? 0 : 1)
} label: {
Image(systemName: plan.userRating == 1 ? "hand.thumbsup.fill" : "hand.thumbsup")
.font(.system(size: 17))
.foregroundColor(plan.userRating == 1 ? .mealMoodSuccess : .mealMoodTextSecondary)
}
Button {
viewModel.rateWeek(plan: plan, rating: plan.userRating == -1 ? 0 : -1)
} label: {
Image(systemName: plan.userRating == -1 ? "hand.thumbsdown.fill" : "hand.thumbsdown")
.font(.system(size: 17))
.foregroundColor(plan.userRating == -1 ? .mealMoodCoral : .mealMoodTextSecondary)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color.mealMoodSurface)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private func dishDrawer(plan: WeekPlan, settings: AppSettings) -> some View {
DishDrawerView(
dishes: dishes,
tags: tags,
language: settings.languageEnum.resolved(),
usedDishIds: Set(plan.slotList.compactMap(\.dishId)),
usageRanking: dishUsageCounts,
onAddDish: { attemptCreateDish() },
onQuickAssignDish: { dish in
viewModel.assignDishToFirstFreeSlot(
dish,
plan: plan,
settings: settings,
allTags: tags,
allDishes: dishes
)
},
onEditDish: { dish in
editingDish = dish
},
onDeleteDish: { dish in
let isAssignedInCurrentWeek = plan.slotList.contains { $0.dishId == dish.id }
if isAssignedInCurrentWeek {
viewModel.toastMessage = localizedString("dish_delete_blocked_message", language: settings.languageEnum.resolved())
viewModel.showToast = true
return
}
context.delete(dish)
try? context.save()
viewModel.toastMessage = localizedString("toast_dish_deleted", language: settings.languageEnum.resolved())
viewModel.showToast = true
},
draggedDish: $viewModel.draggedDish
)
}
private func canNavigateToNextWeek(settings: AppSettings) -> Bool {
if settings.isPremium { return true }
let maxFreeWeek = Date().startOfWeek().addingDays(7)
return viewModel.currentWeekStart < maxFreeWeek
}
private func canNavigateToWeek(_ startDate: Date, settings: AppSettings) -> Bool {
if settings.isPremium { return true }
let maxFreeWeek = Date().startOfWeek().addingDays(7)
return startDate <= maxFreeWeek
}
private func jumpToSelectedWeek() {
guard let settings else { return }
let selectedWeekStart = weekPickerDate.startOfWeek()
if canNavigateToWeek(selectedWeekStart, settings: settings) {
viewModel.jumpToWeek(startDate: selectedWeekStart)
showWeekLimitUpsell = false
} else {
withAnimation(.easeInOut(duration: 0.2)) {
showWeekLimitUpsell = true
}
AnalyticsService.logWeekLimitHit()
paywallPresentation = PaywallPresentation(source: "week_picker")
HapticManager.shared.notification(type: .warning)
}
}
private func isWeekComplete(plan: WeekPlan) -> Bool {
plan.slotList.allSatisfy { $0.dishId != nil || $0.isEatingOut || $0.isSkipped }
}
private var dishUsageCounts: [UUID: Int] {
var counts: [UUID: Int] = [:]
for plan in weekPlans {
for slot in plan.slotList {
if let dishId = slot.dishId {
counts[dishId, default: 0] += 1
}
}
}
return counts
}
@ViewBuilder
private func autoAssignBanner(emptyCount: Int, plan: WeekPlan, settings: AppSettings) -> some View {
VStack(spacing: 10) {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text("home_auto_assign_cta_title")
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
Text(String(format: String(localized: "home_auto_assign_cta_slots"), emptyCount))
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
Spacer()
Button {
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings, allPlans: weekPlans)
} label: {
HStack(spacing: 6) {
Image(systemName: "wand.and.stars")
Text("home_auto_assign_cta_button")
.font(.mealMoodSmall.weight(.semibold))
}
.foregroundColor(.white)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Color.mealMoodCoral)
.clipShape(Capsule())
}
.buttonStyle(.plain)
.disabled(viewModel.isAutoCompleting)
}
if previousWeekHasMenu() {
Button {
requestCopyPreviousWeek(plan: plan, settings: settings, source: "empty_banner")
} label: {
HStack(spacing: 6) {
Image(systemName: "doc.on.doc")
Text("home_copy_previous_week")
.font(.mealMoodSmall.weight(.semibold))
}
.foregroundColor(.mealMoodCoral)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Capsule().fill(Color.white.opacity(0.75)))
.overlay(Capsule().stroke(Color.mealMoodCoral.opacity(0.4), lineWidth: 1))
}
.buttonStyle(.plain)
.disabled(viewModel.isAutoCompleting)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.mealMoodCoral.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.mealMoodCoral.opacity(0.25), lineWidth: 1)
)
.padding(.horizontal, 16)
}
private func updateWidget(settings: AppSettings) {
let todayPlan = fetchWeekPlan(for: Date().startOfWeek())
WidgetDataStore.update(plan: todayPlan, dishes: dishes, settings: settings)
// Week snapshot: stored locally for the iOS week widget and pushed to
// the watch app/complication via WatchConnectivity.
if let payload = WatchSyncService.makePayload(plan: todayPlan, dishes: dishes, settings: settings) {
payload.store()
}
WatchSyncService.shared.push(plan: todayPlan, dishes: dishes, settings: settings)
}
@ViewBuilder
private func exportCallout(plan: WeekPlan, settings: AppSettings) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
Text("share_week_callout_title")
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
Spacer()
Button {
withAnimation { dismissExportCallout(plan) }
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 18))
.foregroundColor(.mealMoodTextSecondary.opacity(0.7))
}
.buttonStyle(.plain)
.accessibilityLabel(Text("dish_cancel"))
}
Text("share_week_callout_subtitle")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
if settings.isPremium {
Button {
startShareFlow(plan: plan, settings: settings)
} label: {
Label("share_week_button", systemImage: "square.and.arrow.up")
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.white.opacity(0.75))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
} else {
Button {
paywallPresentation = PaywallPresentation(source: "share_week")
} label: {
Label("share_week_button", systemImage: "star.fill")
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.white.opacity(0.75))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.mealMoodMint.opacity(0.55))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.mealMoodCoral.opacity(0.4), lineWidth: 1)
)
.padding(.horizontal, 16)
}
private func previousWeekHasMenu() -> Bool {
guard let previous = fetchWeekPlan(for: viewModel.currentWeekStart.addingDays(-7)) else { return false }
return previous.slotList.contains { $0.dishId != nil }
}
/// Copies the previous week, asking to confirm first only when the current
/// week already has dishes that would be overwritten.
private func requestCopyPreviousWeek(plan: WeekPlan, settings: AppSettings, source: String) {
copyPreviousSource = source
if plan.slotList.contains(where: { $0.dishId != nil }) {
showCopyPreviousConfirm = true
} else {
copyFromPreviousWeek(currentPlan: plan, settings: settings)
}
}
private func copyFromPreviousWeek(currentPlan: WeekPlan, settings: AppSettings) {
let previousPlan = fetchWeekPlan(for: viewModel.currentWeekStart.addingDays(-7))
viewModel.copyFromPreviousWeek(
currentPlan: currentPlan,
previousPlan: previousPlan,
settings: settings,
allTags: tags,
allDishes: dishes,
source: copyPreviousSource
)
}
/// Entry point for the toolbar share action: premium shares directly, free
/// is routed to the paywall (same gating as the export banner).
private func requestWeekShare(plan: WeekPlan, settings: AppSettings) {
if settings.isPremium {
startShareFlow(plan: plan, settings: settings)
} else {
paywallPresentation = PaywallPresentation(source: "share_week")
}
}
private func startShareFlow(plan: WeekPlan, settings: AppSettings) {
exportPreviewPlan = plan
}
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)) ?? []
}
private func preferredWeekPlan(from plans: [WeekPlan]) -> WeekPlan? {
plans.max { lhs, rhs in
let lhsAssigned = lhs.slotList.filter { $0.dishId != nil }.count
let rhsAssigned = rhs.slotList.filter { $0.dishId != nil }.count
if lhsAssigned != rhsAssigned { return lhsAssigned < rhsAssigned }
if lhs.slotList.count != rhs.slotList.count { return lhs.slotList.count < rhs.slotList.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 var widgetPromoCard: some View {
HStack(spacing: 12) {
Image(systemName: "apps.iphone.badge.plus")
.font(.system(size: 24))
.foregroundColor(.mealMoodCoral)
VStack(alignment: .leading, spacing: 2) {
Text("widget_promo_title")
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
Text("widget_promo_body")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
Button {
dismissWidgetPromo()
} label: {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.mealMoodTextSecondary)
.frame(width: 28, height: 28)
.background(Color.mealMoodBackground)
.clipShape(Circle())
}
.buttonStyle(.plain)
}
.padding(14)
.background(Color.mealMoodSurface)
.cornerRadius(14)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
)
}
private func evaluateWidgetPromo() {
guard !UserDefaults.standard.bool(forKey: Self.widgetPromoDismissedKey) else { return }
let sessions = UserDefaults.standard.integer(forKey: Self.homeSessionCountKey) + 1
UserDefaults.standard.set(sessions, forKey: Self.homeSessionCountKey)
// From the 2nd session on: the user has seen the app's value, now make it sticky.
guard sessions >= 2 else { return }
if !showWidgetPromo {
showWidgetPromo = true
AnalyticsService.logEvent("widget_promo_shown")
}
}
private func dismissWidgetPromo() {
UserDefaults.standard.set(true, forKey: Self.widgetPromoDismissedKey)
withAnimation(.easeOut(duration: 0.2)) {
showWidgetPromo = false
}
AnalyticsService.logEvent("widget_promo_dismissed")
}
/// Gate every "create dish" entry point on the free limit the check used
/// to live only in the form's save button, so a user at the limit could
/// open and fill the whole form before hitting the paywall.
private func attemptCreateDish() {
let isPremium = allSettings.first?.isPremium ?? false
if PremiumAccess.hasReachedFreeDishLimit(dishCount: dishes.count, isPremium: isPremium) {
AnalyticsService.logDishLimitHit()
HapticManager.shared.notification(type: .warning)
showDishLimitModal = true
} else {
viewModel.showDishForm = true
}
}
private func evaluateReviewPrompt() {
let descriptor = FetchDescriptor<WeekPlan>()
guard let plans = try? context.fetch(descriptor) else { return }
let completedWeeks = plans.filter { !$0.slotList.isEmpty && $0.slotList.allSatisfy { $0.dishId != nil } }.count
guard ReviewPromptService.shared.shouldShowFunnelAfterWeekCompletion(completedWeeks: completedWeeks) else { return }
ReviewPromptService.shared.markFunnelShown(completedWeeks: completedWeeks)
AnalyticsService.logEvent("review_funnel_shown", parameters: ["completed_weeks": completedWeeks])
showReviewSentimentPrompt = true
}
private func evaluatePostOnboardingPromptsIfNeeded(plan: WeekPlan, settings: AppSettings) {
guard !hasEvaluatedPostOnboardingPrompts else { return }
hasEvaluatedPostOnboardingPrompts = true
// Auto-fill the first week so the user lands on a ready plan (with confetti)
// instead of being asked. Reuses the home's autoComplete choreography.
if UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingAutoFillOnLaunchKey) {
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoFillOnLaunchKey)
if !dishes.isEmpty && plan.slotList.contains(where: { $0.dishId == nil && !$0.isEatingOut && !$0.isSkipped }) {
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings, allPlans: weekPlans)
}
schedulePostOnboardingPremiumPromptIfNeeded(settings: settings)
return
}
let shouldAskAutoAssign = UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
if shouldAskAutoAssign && plan.slotList.contains(where: { $0.dishId == nil }) {
showPostOnboardingAutoAssignPrompt = true
return
}
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
schedulePostOnboardingPremiumPromptIfNeeded(settings: settings)
}
private func schedulePostOnboardingPremiumPromptIfNeeded(settings: AppSettings) {
guard !settings.isPremium else {
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey)
return
}
let shouldShowPremiumPrompt = UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingPremiumPromptKey)
guard shouldShowPremiumPrompt else { return }
// The paywall was just shown as the final onboarding step prompting again
// 2s later is prompt fatigue. Defer to the second session instead.
let sessionCountKey = "post_onboarding_session_count"
let sessions = UserDefaults.standard.integer(forKey: sessionCountKey) + 1
UserDefaults.standard.set(sessions, forKey: sessionCountKey)
guard sessions >= 2 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
showPostOnboardingPremiumPrompt = true
}
}
}
private struct RuleViolationsPanelSheet: View {
let plan: WeekPlan
let dishes: [Dish]
let tags: [Tag]
let settings: AppSettings
let onFix: (MealSlot) -> Void
let onReplace: (MealSlot) -> Void
let onIgnore: (MealSlot) -> Void
@Environment(\.dismiss) private var dismiss
private var violations: [AutocompleteEngine.RuleViolation] {
AutocompleteEngine.findViolations(plan: plan, allDishes: dishes, allTags: tags)
}
var body: some View {
NavigationStack {
Group {
if violations.isEmpty {
VStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 44))
.foregroundColor(.mealMoodSuccess)
Text("violations_panel_empty")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(24)
} else {
List {
ForEach(violations, id: \.slotId) { violation in
if let slot = plan.slotList.first(where: { $0.id == violation.slotId }) {
ViolationRow(
violation: violation,
settings: settings,
tags: tags,
onFix: {
onFix(slot)
if violations.count <= 1 { dismiss() }
},
onReplace: {
onReplace(slot)
dismiss()
},
onIgnore: {
onIgnore(slot)
if violations.count <= 1 { dismiss() }
}
)
.listRowBackground(Color.mealMoodSurface)
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
.background(Color.mealMoodBackground.ignoresSafeArea())
.navigationTitle("violations_panel_title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("tag_selector_done") { dismiss() }
}
}
}
}
private struct ViolationRow: View {
let violation: AutocompleteEngine.RuleViolation
let settings: AppSettings
let tags: [Tag]
let onFix: () -> Void
let onReplace: () -> Void
let onIgnore: () -> Void
private var language: AppLanguage { settings.languageEnum.resolved() }
private var dayLabel: String {
localizedString(dayKey(for: violation.dayOfWeek), language: language)
}
private var mealLabel: String {
localizedString(violation.mealType, language: language)
}
private func tagName(_ id: UUID?) -> String {
guard let id, let tag = tags.first(where: { $0.id == id }) else { return "" }
return tag.localizedName(language: language)
}
/// Plain-language "what rule is broken" for each reason.
private func explanation(_ reason: AutocompleteEngine.RuleViolation.Reason) -> String {
let tag = tagName(reason.tagId)
switch reason.kind {
case .repeatedInWeek:
return String(localized: "violation_reason_repeated")
case .maxPerWeek:
return String(format: String(localized: "violation_reason_max_per_week"), tag, reason.limit ?? 0)
case .noConsecutive:
return String(format: String(localized: "violation_reason_no_consecutive"), tag)
case .noDuplicateInDay:
return String(format: String(localized: "violation_reason_no_same_day"), tag)
case .mealTypeOnly:
let meal = localizedString(reason.restriction ?? "", language: language)
return String(format: String(localized: "violation_reason_meal_only"), tag, meal)
case .dayRestriction:
let key = reason.restriction == "weekend" ? "violation_scope_weekend" : "violation_scope_weekdays"
return String(format: String(localized: "violation_reason_day_scope"), tag, String(localized: String.LocalizationValue(key)))
}
}
/// What the user can do about it.
private var suggestion: String {
guard let first = violation.reasons.first else {
return String(localized: "violation_suggestion_generic")
}
switch first.kind {
case .repeatedInWeek:
return String(localized: "violation_suggestion_repeated")
case .maxPerWeek, .noConsecutive, .noDuplicateInDay:
return String(localized: "violation_suggestion_swap")
case .mealTypeOnly, .dayRestriction:
return String(localized: "violation_suggestion_move")
}
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.mealMoodWarning)
.font(.system(size: 14))
Text("\(dayLabel) · \(mealLabel)")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
}
Text(violation.dishName)
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
if !violation.reasons.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(violation.reasons.enumerated()), id: \.offset) { _, reason in
HStack(alignment: .top, spacing: 6) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 11))
.foregroundColor(.mealMoodError)
Text(explanation(reason))
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextPrimary)
}
}
HStack(alignment: .top, spacing: 6) {
Image(systemName: "lightbulb.fill")
.font(.system(size: 11))
.foregroundColor(.mealMoodWarning)
Text(suggestion)
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.mealMoodWarning.opacity(0.10))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
HStack(spacing: 8) {
Button(action: onReplace) {
Text("violations_replace")
.font(.mealMoodSmall)
.foregroundColor(.white)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(Color.mealMoodCoral)
.clipShape(Capsule())
}
.buttonStyle(.plain)
Button(action: onFix) {
Text("violations_fix")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodCoral)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(Color.mealMoodCoral.opacity(0.12))
.clipShape(Capsule())
}
.buttonStyle(.plain)
Button(action: onIgnore) {
Text("violations_ignore")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(Color(hex: "#F0F0F0"))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 8)
}
private func dayKey(for day: Int) -> String {
let keys = ["day_mon","day_tue","day_wed","day_thu","day_fri","day_sat","day_sun"]
guard day >= 0 && day < keys.count else { return "" }
return keys[day]
}
}
}
private struct ShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
/// Export screen: live preview of the rendered week image with a style switcher,
/// so the user can compare styles before sharing. Doesn't touch the saved default
/// (unless none was chosen yet then the shared style becomes it).
private struct WeekExportPreviewSheet: View {
let plan: WeekPlan
let settings: AppSettings
let dishes: [Dish]
let tags: [Tag]
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@State private var selectedStyle: WeekExportStyle
@State private var previews: [WeekExportStyle: UIImage] = [:]
@State private var shareFile: ShareFile?
@State private var didCopyText: Bool = false
private struct ShareFile: Identifiable {
let url: URL
var id: String { url.absoluteString }
}
init(plan: WeekPlan, settings: AppSettings, dishes: [Dish], tags: [Tag]) {
self.plan = plan
self.settings = settings
self.dishes = dishes
self.tags = tags
_selectedStyle = State(initialValue: settings.weekExportStyleEnum)
}
var body: some View {
NavigationStack {
VStack(spacing: 14) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(WeekExportStyle.allCases, id: \.self) { style in
Button {
selectedStyle = style
} label: {
Text(LocalizedStringKey(style.localizedKey))
.font(.mealMoodSmall.weight(.semibold))
.foregroundColor(selectedStyle == style ? .white : .mealMoodTextPrimary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(selectedStyle == style ? Color.mealMoodCoral : Color.mealMoodSurface)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
}
.padding(.top, 10)
Group {
if let image = previews[selectedStyle] {
ScrollView {
Image(uiImage: image)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 10))
.shadow(color: .black.opacity(0.12), radius: 8, y: 3)
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
} else {
VStack(spacing: 10) {
ProgressView()
Text("share_export_preview_rendering")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
PrimaryButton(
title: String(localized: "share_export_share_button"),
action: shareCurrentStyle,
isEnabled: previews[selectedStyle] != nil
)
.padding(.horizontal, 16)
Button(action: copyAsText) {
Label(
String(localized: didCopyText ? "share_text_copied" : "share_export_copy_text"),
systemImage: didCopyText ? "checkmark.circle.fill" : "doc.on.doc"
)
.font(.mealMoodBodyBold)
.foregroundColor(didCopyText ? .mealMoodSuccess : .mealMoodCoral)
}
.padding(.bottom, 12)
}
.background(Color.mealMoodBackground.ignoresSafeArea())
.navigationTitle("share_export_preview_title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("reset_cancel") { dismiss() }
.foregroundColor(.mealMoodTextSecondary)
}
}
.task(id: selectedStyle) { renderIfNeeded(selectedStyle) }
.sheet(item: $shareFile) { file in
SocialShareSheet(imageURL: file.url)
}
}
}
@MainActor
private func renderIfNeeded(_ style: WeekExportStyle) {
guard previews[style] == nil else { return }
let renderer = ImageRenderer(content: WeekPlanShareView(
plan: plan, settings: settings, dishes: dishes, tags: tags,
exportStyleOverride: style
))
let canvasSize = WeekPlanShareView.canvasSize(for: style)
renderer.proposedSize = ProposedViewSize(width: canvasSize.width, height: canvasSize.height)
renderer.scale = 1
if let image = renderer.uiImage {
previews[style] = image
}
}
private func shareCurrentStyle() {
guard let image = previews[selectedStyle], let data = image.pngData() else { return }
let timestamp = Int(Date().timeIntervalSince1970 * 1000)
let filename = "mealmood-week-plan-\(selectedStyle.rawValue)-\(timestamp).png"
let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
try? data.write(to: url, options: .atomic)
if settings.weekExportStyle == nil {
settings.weekExportStyleEnum = selectedStyle
try? context.save()
}
AnalyticsService.logWeekPlanShared(format: selectedStyle.rawValue)
shareFile = ShareFile(url: url)
}
private func copyAsText() {
UIPasteboard.general.string = WeekPlanTextExporter.text(plan: plan, settings: settings, dishes: dishes)
AnalyticsService.logWeekPlanShared(format: "text")
HapticManager.shared.notification(type: .success)
withAnimation { didCopyText = true }
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation { didCopyText = false }
}
}
}
private struct MonthlyHistoryView: View {
let weekPlans: [WeekPlan]
let currentWeekStart: Date
let language: AppLanguage
let onSelectWeek: (Date) -> Void
@Environment(\.dismiss) private var dismiss
@State private var monthCursor: Date
init(
weekPlans: [WeekPlan],
currentWeekStart: Date,
language: AppLanguage,
onSelectWeek: @escaping (Date) -> Void
) {
self.weekPlans = weekPlans
self.currentWeekStart = currentWeekStart
self.language = language
self.onSelectWeek = onSelectWeek
_monthCursor = State(initialValue: currentWeekStart.startOfMonth())
}
private var locale: Locale {
Locale(identifier: language.localeIdentifier)
}
private var monthPlans: [WeekPlan] {
let calendar = Calendar.current
return weekPlans
.filter {
calendar.component(.year, from: $0.weekStartDate) == calendar.component(.year, from: monthCursor) &&
calendar.component(.month, from: $0.weekStartDate) == calendar.component(.month, from: monthCursor)
}
.sorted { $0.weekStartDate > $1.weekStartDate }
}
var body: some View {
NavigationStack {
VStack(spacing: 14) {
monthHeader
if monthPlans.isEmpty {
Text("history_month_empty")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.padding(.top, 24)
} else {
List(monthPlans, id: \.id) { plan in
Button {
onSelectWeek(plan.weekStartDate)
dismiss()
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(weekLabel(for: plan.weekStartDate))
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
Text(plan.slotList.allSatisfy { $0.dishId != nil } ? String(localized: "history_week_complete") : String(localized: "history_week_incomplete"))
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.mealMoodTextSecondary)
}
.padding(.vertical, 4)
}
.listRowBackground(Color.mealMoodSurface)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
.padding(.horizontal, 16)
.padding(.top, 10)
.background(Color.mealMoodBackground.ignoresSafeArea())
.navigationTitle("history_title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("tag_selector_done") { dismiss() }
}
}
}
}
private var monthHeader: some View {
HStack {
Button {
monthCursor = monthCursor.addingMonths(-1)
} label: {
Image(systemName: "chevron.left")
.foregroundColor(.mealMoodCoral)
}
Spacer()
Text(monthCursor.monthYearLabel(locale: locale))
.font(.mealMoodH3)
.foregroundColor(.mealMoodTextPrimary)
Spacer()
Button {
monthCursor = monthCursor.addingMonths(1)
} label: {
Image(systemName: "chevron.right")
.foregroundColor(.mealMoodCoral)
}
}
}
private func weekLabel(for startDate: Date) -> String {
let endDate = startDate.addingDays(6)
let dayFormatter = DateFormatter()
dayFormatter.locale = locale
dayFormatter.setLocalizedDateFormatFromTemplate("d")
let monthFormatter = DateFormatter()
monthFormatter.locale = locale
monthFormatter.setLocalizedDateFormatFromTemplate("MMM")
return "\(dayFormatter.string(from: startDate))-\(dayFormatter.string(from: endDate)) \(monthFormatter.string(from: endDate))"
}
}