Version casi lista
This commit is contained in:
@@ -0,0 +1,752 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct HomeView: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.modelContext) private var context
|
||||
@Query 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 showWeekLimitUpsell: Bool = false
|
||||
@State private var wasWeekComplete: Bool = false
|
||||
@State private var selectedEmptySlotId: UUID?
|
||||
@State private var showWeekPicker: Bool = false
|
||||
@State private var weekPickerDate: Date = Date()
|
||||
@State private var showCopyPreviousConfirm: Bool = false
|
||||
|
||||
private var settings: AppSettings? { allSettings.first }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
if let settings = settings,
|
||||
let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) {
|
||||
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 {
|
||||
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 = viewModel.getOrCreateWeekPlan(context: context, settings: settings) {
|
||||
if viewModel.canEditCurrentWeek {
|
||||
Button {
|
||||
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings)
|
||||
} 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")
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
if horizontalSizeClass == .regular {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
VStack(spacing: 10) {
|
||||
WeekCalendarView(
|
||||
plan: plan,
|
||||
settings: settings,
|
||||
dishes: dishes,
|
||||
tags: tags,
|
||||
viewModel: viewModel,
|
||||
onTapEmptySlot: { slot in
|
||||
selectedEmptySlotId = slot.id
|
||||
}
|
||||
)
|
||||
|
||||
if isWeekComplete(plan: plan) {
|
||||
exportCallout(plan: plan, settings: settings)
|
||||
}
|
||||
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ScrollView(showsIndicators: true) {
|
||||
dishDrawer(plan: plan, settings: settings)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
}
|
||||
.frame(width: 360)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 0)
|
||||
} else {
|
||||
VStack(spacing: 10) {
|
||||
WeekCalendarView(
|
||||
plan: plan,
|
||||
settings: settings,
|
||||
dishes: dishes,
|
||||
tags: tags,
|
||||
viewModel: viewModel,
|
||||
onTapEmptySlot: { slot in
|
||||
selectedEmptySlotId = slot.id
|
||||
}
|
||||
)
|
||||
|
||||
if isWeekComplete(plan: plan) {
|
||||
exportCallout(plan: plan, settings: settings)
|
||||
}
|
||||
|
||||
ScrollView(showsIndicators: true) {
|
||||
dishDrawer(plan: plan, settings: settings)
|
||||
.padding(.bottom, 0)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
.padding(.bottom, 0)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if !settings.isPremium {
|
||||
AdBannerView()
|
||||
.ignoresSafeArea(.container, edges: .bottom)
|
||||
}
|
||||
}
|
||||
.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"))
|
||||
)
|
||||
}
|
||||
.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 {
|
||||
// In picker flow, assign anyway and mark as override so the action is never lost.
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingDish) { dish in
|
||||
DishFormView(dish: dish)
|
||||
}
|
||||
.sheet(isPresented: $showPremiumFromExport) {
|
||||
NavigationStack {
|
||||
PremiumView(settings: settings)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showMonthlyHistory) {
|
||||
MonthlyHistoryView(
|
||||
weekPlans: weekPlans,
|
||||
currentWeekStart: viewModel.currentWeekStart,
|
||||
language: settings.languageEnum.resolved()
|
||||
) { weekStart in
|
||||
viewModel.jumpToWeek(startDate: weekStart)
|
||||
showMonthlyHistory = false
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.onChange(of: plan.updatedAt) { _, _ in
|
||||
let nowComplete = isWeekComplete(plan: plan)
|
||||
if nowComplete && !wasWeekComplete {
|
||||
evaluateReviewPrompt()
|
||||
}
|
||||
wasWeekComplete = nowComplete
|
||||
}
|
||||
}
|
||||
|
||||
private struct SlotDishPickerSheet: View {
|
||||
let dishes: [Dish]
|
||||
let tags: [Tag]
|
||||
let onPickDish: (Dish) -> Void
|
||||
let onCreateDish: () -> Void
|
||||
|
||||
@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 {
|
||||
ForEach(filteredDishes) { 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)),
|
||||
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 }
|
||||
}
|
||||
|
||||
@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,
|
||||
let image = renderWeekShareImage(plan: plan, settings: settings),
|
||||
let shareURL = persistShareImage(image) {
|
||||
ShareLink(
|
||||
item: shareURL,
|
||||
preview: SharePreview(String(localized: "share_week_title"), image: Image(uiImage: image))
|
||||
) {
|
||||
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))
|
||||
}
|
||||
} 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 renderWeekShareImage(plan: WeekPlan, settings: AppSettings) -> UIImage? {
|
||||
let renderer = ImageRenderer(content: WeekPlanShareView(plan: plan, settings: settings, dishes: dishes, tags: tags))
|
||||
renderer.proposedSize = ProposedViewSize(width: 2400, height: 1700)
|
||||
renderer.scale = 1
|
||||
return renderer.uiImage
|
||||
}
|
||||
|
||||
private func persistShareImage(_ image: UIImage) -> URL? {
|
||||
guard let data = image.pngData() else { return nil }
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("mealmood-week-plan.png")
|
||||
try? data.write(to: url, options: .atomic)
|
||||
return url
|
||||
}
|
||||
|
||||
private func fetchWeekPlan(for weekStartDate: Date) -> WeekPlan? {
|
||||
let descriptor = FetchDescriptor<WeekPlan>(
|
||||
predicate: #Predicate<WeekPlan> { plan in
|
||||
plan.weekStartDate == weekStartDate
|
||||
}
|
||||
)
|
||||
return try? context.fetch(descriptor).first
|
||||
}
|
||||
|
||||
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
|
||||
ReviewPromptService.shared.considerPromptAfterWeekCompletion(completedWeeks: completedWeeks)
|
||||
}
|
||||
}
|
||||
|
||||
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))"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user