Files
FamilyMealPlanner/MealMood/Views/Home/HomeView.swift
T
alexandrev-tibco ff5c6c7895 home: aviso visible de incumplimientos con acceso al detalle
El panel de avisos solo se alcanzaba desde el menu "..." de la barra, asi
que los incumplimientos pasaban desapercibidos. Ahora, cuando la semana
rompe reglas, aparece un banner bajo el selector de semana con el numero
de comidas afectadas, un resumen de donde estan (Lun - Cena, Mie -
Comida) y un boton Ver que abre el panel con la explicacion y el arreglo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
2026-09-10 21:14:49 +02:00

1877 lines
82 KiB
Swift

import SwiftUI
import SwiftData
import UIKit
struct HomeView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.modelContext) private var context
@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 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(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"))
}
.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(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: 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())
}
.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))"
}
}