ff5c6c7895
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
1877 lines
82 KiB
Swift
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))"
|
|
}
|
|
}
|