330f21a019
- AutocompleteEngine: MRV heuristic picks the most-constrained slot first at each step; historical scoring deprioritises dishes used in recent weeks (decay: 0.20 last week → 0.90 four+ weeks ago) - HomeViewModel: passes last 4 weeks as recentPlans to autocomplete - StatsView (Premium): streak, weeks planned, completion rate, top 8 dishes with bar charts, top 6 tags with colour bars — 6 languages - StoreManager: update product ID to approved bundle-ID convention - Version bump to 1.1.3 / build 48 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1375 lines
58 KiB
Swift
1375 lines
58 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?
|
|
@State private var showPremiumFromExport: 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 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 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 }
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
|
|
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
|
|
}
|
|
showPremiumFromExport = true
|
|
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 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 {
|
|
if plan.slots.contains(where: { $0.dishId != nil }) {
|
|
showCopyPreviousConfirm = true
|
|
} else {
|
|
copyFromPreviousWeek(currentPlan: plan, settings: settings)
|
|
}
|
|
} 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?.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"
|
|
) {
|
|
showPremiumFromExport = true
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 8)
|
|
}
|
|
|
|
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
|
|
}
|
|
)
|
|
|
|
let filledCount = plan.slots.filter { $0.dishId != nil || $0.isEatingOut }.count
|
|
if filledCount > 0 {
|
|
exportCallout(plan: plan, settings: settings)
|
|
}
|
|
|
|
let emptyCount = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut }.count
|
|
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)
|
|
.frame(maxWidth: .infinity, alignment: .top)
|
|
.padding(.horizontal, contentHorizontalPadding)
|
|
.padding(.bottom, 0)
|
|
}
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
|
if !settings.isPremium && !isRunningOnMac {
|
|
AdBannerView()
|
|
}
|
|
}
|
|
.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)
|
|
showPremiumFromExport = true
|
|
}
|
|
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") {
|
|
ReviewPromptService.shared.requestReview()
|
|
ReviewPromptService.shared.markReviewCompleted()
|
|
}
|
|
Button("review_funnel_negative", role: .destructive) {
|
|
showReviewSupportPrompt = true
|
|
}
|
|
Button("reset_cancel", role: .cancel) {}
|
|
} 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: $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.slots.contains(where: { $0.id == slotId }) {
|
|
SlotDishPickerSheet(
|
|
dishes: dishes,
|
|
tags: tags,
|
|
onPickDish: { dish in
|
|
guard let freshSlot = plan.slots.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) {
|
|
viewModel.showDishForm = true
|
|
}
|
|
},
|
|
onEatingOut: {
|
|
guard let freshSlot = plan.slots.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.slots.contains(where: { $0.id == slotId }) {
|
|
SlotDishPickerSheet(
|
|
dishes: dishes,
|
|
tags: tags,
|
|
onPickDish: { dish in
|
|
guard let freshSlot = plan.slots.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) {
|
|
viewModel.showDishForm = true
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.sheet(item: $editingDish) { dish in
|
|
DishFormView(dish: dish)
|
|
}
|
|
.sheet(isPresented: $showPremiumFromExport) {
|
|
NavigationStack {
|
|
PremiumView(settings: settings)
|
|
}
|
|
}
|
|
.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())
|
|
}
|
|
.task {
|
|
await NotificationService.shared.requestPermissionIfNeeded()
|
|
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)
|
|
updateWidget(settings: settings)
|
|
}
|
|
.onChange(of: plan.updatedAt) { _, _ in
|
|
let nowComplete = isWeekComplete(plan: plan)
|
|
if nowComplete && !wasWeekComplete {
|
|
evaluateReviewPrompt()
|
|
}
|
|
wasWeekComplete = nowComplete
|
|
updateWidget(settings: settings)
|
|
}
|
|
}
|
|
|
|
private struct SlotDishPickerSheet: View {
|
|
let dishes: [Dish]
|
|
let tags: [Tag]
|
|
let onPickDish: (Dish) -> Void
|
|
let onCreateDish: () -> Void
|
|
var onEatingOut: (() -> Void)? = nil
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var searchText: String = ""
|
|
|
|
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"))
|
|
}
|
|
|
|
ForEach(filteredDishes, id: \.id) { dish in
|
|
Button {
|
|
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 dishDrawer(plan: WeekPlan, settings: AppSettings) -> some View {
|
|
DishDrawerView(
|
|
dishes: dishes,
|
|
tags: tags,
|
|
language: settings.languageEnum.resolved(),
|
|
usedDishIds: Set(plan.slots.compactMap(\.dishId)),
|
|
usageRanking: dishUsageCounts,
|
|
onAddDish: { viewModel.showDishForm = true },
|
|
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.slots.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
|
|
}
|
|
showPremiumFromExport = true
|
|
HapticManager.shared.notification(type: .warning)
|
|
}
|
|
}
|
|
|
|
private func isWeekComplete(plan: WeekPlan) -> Bool {
|
|
plan.slots.allSatisfy { $0.dishId != nil || $0.isEatingOut }
|
|
}
|
|
|
|
private var dishUsageCounts: [UUID: Int] {
|
|
var counts: [UUID: Int] = [:]
|
|
for plan in weekPlans {
|
|
for slot in plan.slots {
|
|
if let dishId = slot.dishId {
|
|
counts[dishId, default: 0] += 1
|
|
}
|
|
}
|
|
}
|
|
return counts
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func autoAssignBanner(emptyCount: Int, plan: WeekPlan, settings: AppSettings) -> some View {
|
|
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)
|
|
}
|
|
.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) {
|
|
Text("share_week_callout_title")
|
|
.font(.mealMoodBodyBold)
|
|
.foregroundColor(.mealMoodTextPrimary)
|
|
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 {
|
|
showPremiumFromExport = true
|
|
} 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 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
|
|
)
|
|
}
|
|
|
|
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<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.slots.filter { $0.dishId != nil }.count
|
|
let rhsAssigned = rhs.slots.filter { $0.dishId != nil }.count
|
|
if lhsAssigned != rhsAssigned { return lhsAssigned < rhsAssigned }
|
|
if lhs.slots.count != rhs.slots.count { return lhs.slots.count < rhs.slots.count }
|
|
return lhs.updatedAt < rhs.updatedAt
|
|
}
|
|
}
|
|
|
|
private func ensureCurrentWeekPlanExists(settings: AppSettings) -> WeekPlan? {
|
|
let matches = weekPlanCandidates(for: viewModel.currentWeekStart)
|
|
if let preferred = preferredWeekPlan(from: matches) {
|
|
if matches.count > 1 {
|
|
for duplicate in matches where duplicate.id != preferred.id {
|
|
context.delete(duplicate)
|
|
}
|
|
try? context.save()
|
|
}
|
|
return preferred
|
|
}
|
|
return DefaultDataService.createWeekPlan(for: viewModel.currentWeekStart, settings: settings, context: context)
|
|
}
|
|
|
|
private func evaluateReviewPrompt() {
|
|
let descriptor = FetchDescriptor<WeekPlan>()
|
|
guard let plans = try? context.fetch(descriptor) else { return }
|
|
let completedWeeks = plans.filter { !$0.slots.isEmpty && $0.slots.allSatisfy { $0.dishId != nil } }.count
|
|
guard ReviewPromptService.shared.shouldShowFunnelAfterWeekCompletion(completedWeeks: completedWeeks) else { return }
|
|
ReviewPromptService.shared.markFunnelShown(completedWeeks: completedWeeks)
|
|
showReviewSentimentPrompt = true
|
|
}
|
|
|
|
private func evaluatePostOnboardingPromptsIfNeeded(plan: WeekPlan, settings: AppSettings) {
|
|
guard !hasEvaluatedPostOnboardingPrompts else { return }
|
|
hasEvaluatedPostOnboardingPrompts = true
|
|
|
|
let shouldAskAutoAssign = UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
|
|
if shouldAskAutoAssign && plan.slots.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 }
|
|
|
|
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.slots.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.slots.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))"
|
|
}
|
|
}
|