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 showExportStylePicker: Bool = false @State private var pendingExportPlan: WeekPlan? @State private var shareImageURL: URL? @State private var showShareSheet: Bool = false @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: .principal) { HStack(spacing: 10) { if let settings = settings { Button { viewModel.goToPreviousWeek() showWeekLimitUpsell = false } label: { Image(systemName: "chevron.left") .foregroundColor(.mealMoodCoral) } .accessibilityLabel(Text("home_previous")) Button { weekPickerDate = viewModel.currentWeekStart showWeekPicker = true } label: { VStack(spacing: 2) { Text(viewModel.weekRangeText) .font(.mealMoodH2) .foregroundColor(.mealMoodTextPrimary) } } .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") .foregroundColor(.mealMoodCoral) } .accessibilityLabel(Text("home_next")) } } } 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 }) { 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) } } @ViewBuilder private func mainContent(plan: WeekPlan, settings: AppSettings) -> some View { VStack(spacing: 0) { 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 }.count let emptyCount = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut }.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) } ) } } .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(isPresented: $showExportStylePicker) { WeekExportStylePickerSheet( selectedStyle: settings.weekExportStyleEnum ) { selectedStyle in settings.weekExportStyleEnum = selectedStyle try? context.save() showExportStylePicker = false if let planToShare = pendingExportPlan { prepareShareImage(plan: planToShare, settings: settings) } pendingExportPlan = nil } .presentationDetents([.medium, .large]) } .sheet(isPresented: $showShareSheet) { if let shareImageURL { SocialShareSheet(imageURL: shareImageURL) } } .sheet(isPresented: $showViolationsPanel) { RuleViolationsPanelSheet( plan: plan, dishes: dishes, tags: tags, settings: settings, onFix: { slot in viewModel.removeDish(from: slot, plan: plan, settings: settings) }, 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 /// 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 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 } } 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) } @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) { if settings.weekExportStyle == nil { pendingExportPlan = plan showExportStylePicker = true return } prepareShareImage(plan: plan, settings: settings) } private func prepareShareImage(plan: WeekPlan, settings: AppSettings) { showShareSheet = false shareImageURL = nil guard let image = renderWeekShareImage(plan: plan, settings: settings), let url = persistShareImage(image, style: settings.weekExportStyleEnum) else { return } shareImageURL = url showShareSheet = true AnalyticsService.logWeekPlanShared(format: settings.weekExportStyleEnum.rawValue) } private func renderWeekShareImage(plan: WeekPlan, settings: AppSettings) -> UIImage? { let renderer = ImageRenderer(content: WeekPlanShareView(plan: plan, settings: settings, dishes: dishes, tags: tags)) let canvasSize = WeekPlanShareView.canvasSize(for: settings.weekExportStyleEnum) renderer.proposedSize = ProposedViewSize( width: canvasSize.width, height: canvasSize.height ) renderer.scale = 1 return renderer.uiImage } private func persistShareImage(_ image: UIImage, style: WeekExportStyle) -> URL? { guard let data = image.pngData() else { return nil } let timestamp = Int(Date().timeIntervalSince1970 * 1000) let filename = "mealmood-week-plan-\(style.rawValue)-\(timestamp).png" let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename) try? data.write(to: url, options: .atomic) return url } private func fetchWeekPlan(for weekStartDate: Date) -> WeekPlan? { let matches = weekPlanCandidates(for: weekStartDate) guard !matches.isEmpty else { return nil } return preferredWeekPlan(from: matches) } private func weekPlanCandidates(for weekStartDate: Date) -> [WeekPlan] { let descriptor = FetchDescriptor( predicate: #Predicate { plan in plan.weekStartDate == weekStartDate } ) return (try? context.fetch(descriptor)) ?? [] } 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() 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 }) { 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 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, onFix: { onFix(slot) if violations.count <= 1 { 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 onFix: () -> Void let onIgnore: () -> Void private var dayLabel: String { let language = settings.languageEnum.resolved() return localizedString(dayKey(for: violation.dayOfWeek), language: language) } private var mealLabel: String { let language = settings.languageEnum.resolved() return localizedString(violation.mealType, language: language) } 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) HStack(spacing: 10) { Button(action: onFix) { Text("violations_fix") .font(.mealMoodSmall) .foregroundColor(.white) .padding(.horizontal, 14) .padding(.vertical, 6) .background(Color.mealMoodCoral) .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) {} } private struct WeekExportStylePickerSheet: View { let selectedStyle: WeekExportStyle let onConfirm: (WeekExportStyle) -> Void @State private var temporarySelection: WeekExportStyle init(selectedStyle: WeekExportStyle, onConfirm: @escaping (WeekExportStyle) -> Void) { self.selectedStyle = selectedStyle self.onConfirm = onConfirm _temporarySelection = State(initialValue: selectedStyle) } var body: some View { NavigationStack { VStack(spacing: 14) { Text("share_export_style_picker_title") .font(.mealMoodH2) .foregroundColor(.mealMoodTextPrimary) .padding(.top, 8) Text("share_export_style_picker_subtitle") .font(.mealMoodCaption) .foregroundColor(.mealMoodTextSecondary) ScrollView { VStack(spacing: 12) { ForEach(WeekExportStyle.allCases, id: \.self) { style in Button { temporarySelection = style } label: { VStack(alignment: .leading, spacing: 10) { WeekExportStyleThumbnail(style: style) .frame(height: 110) .clipShape(RoundedRectangle(cornerRadius: 10)) HStack { Text(LocalizedStringKey(style.localizedKey)) .font(.mealMoodBodyBold) .foregroundColor(.mealMoodTextPrimary) Spacer() Image(systemName: temporarySelection == style ? "checkmark.circle.fill" : "circle") .foregroundColor(temporarySelection == style ? .mealMoodCoral : .mealMoodTextSecondary) } } .padding(10) .background(Color.white) .clipShape(RoundedRectangle(cornerRadius: 12)) .overlay( RoundedRectangle(cornerRadius: 12) .stroke( temporarySelection == style ? Color.mealMoodCoral : Color.mealMoodMint.opacity(0.55), lineWidth: temporarySelection == style ? 2 : 1 ) ) } .buttonStyle(.plain) } } .padding(.vertical, 6) } Button("share_export_style_picker_apply") { onConfirm(temporarySelection) } .buttonStyle(.borderedProminent) .tint(.mealMoodCoral) } .padding(.horizontal, 16) .padding(.bottom, 12) .background(Color.mealMoodBackground.ignoresSafeArea()) .navigationBarTitleDisplayMode(.inline) } } } private struct WeekExportStyleThumbnail: View { let style: WeekExportStyle var body: some View { ZStack { switch style { case .defaultStyle: defaultPreview case .schoolTimetable: schoolPreview case .vertical: verticalPreview } } } private var defaultPreview: some View { ZStack { LinearGradient(colors: [Color(hex: "#FFEFE6"), Color(hex: "#F1FBF5")], startPoint: .topLeading, endPoint: .bottomTrailing) VStack(spacing: 6) { RoundedRectangle(cornerRadius: 5) .fill(Color.white.opacity(0.9)) .frame(height: 20) RoundedRectangle(cornerRadius: 8) .fill(Color.white.opacity(0.8)) .frame(height: 62) .overlay( VStack(spacing: 4) { Rectangle().fill(Color.mealMoodCoral.opacity(0.35)).frame(height: 8) Rectangle().fill(Color.mealMoodMint.opacity(0.35)).frame(height: 8) Rectangle().fill(Color(hex: "#E9EDF7")).frame(height: 8) } .padding(8) ) } .padding(8) } } private var schoolPreview: some View { ZStack { Color(hex: "#FFF9F5") VStack(spacing: 5) { RoundedRectangle(cornerRadius: 5) .fill(Color.white) .frame(height: 16) HStack(spacing: 4) { RoundedRectangle(cornerRadius: 4) .fill(Color.mealMoodCoral.opacity(0.5)) .frame(width: 42) VStack(spacing: 4) { Rectangle().fill(Color.mealMoodMint.opacity(0.4)).frame(height: 12) Rectangle().fill(Color(hex: "#FFFDFB")).frame(height: 20) Rectangle().fill(Color(hex: "#FFFDFB")).frame(height: 20) } VStack(spacing: 4) { Rectangle().fill(Color.mealMoodMint.opacity(0.4)).frame(height: 12) Rectangle().fill(Color(hex: "#FFFDFB")).frame(height: 20) Rectangle().fill(Color(hex: "#FFFDFB")).frame(height: 20) } } } .padding(8) } } private var verticalPreview: some View { ZStack { Color(hex: "#FFFDF8") VStack(spacing: 6) { RoundedRectangle(cornerRadius: 5) .fill(Color.white) .frame(height: 18) ForEach(0..<3, id: \.self) { _ in HStack(spacing: 6) { Capsule() .fill(Color.mealMoodCoral.opacity(0.45)) .frame(width: 46, height: 14) VStack(spacing: 4) { Rectangle().fill(Color.mealMoodMint.opacity(0.35)).frame(height: 7) Rectangle().fill(Color(hex: "#EDE8E1")).frame(height: 7) } } .padding(.horizontal, 6) .padding(.vertical, 4) .background(Color.white) .clipShape(RoundedRectangle(cornerRadius: 6)) } } .padding(8) } } } 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))" } }