Version casi lista

This commit is contained in:
alexandrev-tibco
2026-02-17 13:34:02 +01:00
commit e593453abd
99 changed files with 10867 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
import SwiftUI
struct DishDrawerView: View {
let dishes: [Dish]
let tags: [Tag]
let language: AppLanguage
let usedDishIds: Set<UUID>
var onAddDish: () -> Void
var onQuickAssignDish: (Dish) -> Void
var onEditDish: (Dish) -> Void
var onDeleteDish: (Dish) -> Void
@Binding var draggedDish: Dish?
@State private var searchText: String = ""
@State private var hideUsedThisWeek: Bool = false
private var filteredDishes: [Dish] {
let term = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
return dishes.filter { dish in
let matchesSearch = term.isEmpty || dish.name.localizedCaseInsensitiveContains(term)
let matchesUsedFilter = !hideUsedThisWeek || !usedDishIds.contains(dish.id)
return matchesSearch && matchesUsedFilter
}
}
var body: some View {
VStack(spacing: 12) {
HStack {
HStack(spacing: 6) {
Image(systemName: "list.clipboard")
Text("home_my_dishes")
.font(.mealMoodH3)
}
.foregroundColor(.mealMoodTextPrimary)
Spacer()
Button(action: onAddDish) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 28))
.foregroundColor(.mealMoodCoral)
}
}
.padding(.horizontal, 16)
if dishes.isEmpty {
VStack(spacing: 16) {
Image(systemName: "fork.knife")
.font(.system(size: 40))
.foregroundColor(Color(hex: "#C4C4C4"))
Text("home_add_first_dish")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
PrimaryButton(title: String(localized: "home_add_dish"), icon: nil, action: onAddDish)
.padding(.horizontal, 40)
}
.padding(.vertical, 32)
} else {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundColor(.mealMoodTextSecondary)
TextField("home_my_dishes_search", text: $searchText)
.font(.mealMoodBody)
if !searchText.isEmpty {
Button {
searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.mealMoodTextSecondary)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color.mealMoodSurface)
.cornerRadius(12)
.padding(.horizontal, 16)
Button {
hideUsedThisWeek.toggle()
} label: {
HStack(spacing: 8) {
Image(systemName: hideUsedThisWeek ? "checkmark.square.fill" : "square")
.foregroundColor(hideUsedThisWeek ? .mealMoodCoral : .mealMoodTextSecondary)
Text("home_my_dishes_hide_used")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextPrimary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
}
.buttonStyle(.plain)
LazyVStack(spacing: 8) {
ForEach(filteredDishes) { dish in
DishCardView(
dish: dish,
tags: tags,
language: language,
onEdit: { onEditDish(dish) },
onDelete: { onDeleteDish(dish) }
)
.contentShape(Rectangle())
.onTapGesture {
onQuickAssignDish(dish)
}
.swipeActions(edge: .leading, allowsFullSwipe: false) {
Button {
onEditDish(dish)
} label: {
Label("dish_edit_title", systemImage: "pencil")
}
.tint(.mealMoodCoral)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
onDeleteDish(dish)
} label: {
Label("dish_delete", systemImage: "trash")
}
}
.draggable("dish:\(dish.id.uuidString)") {
DishCardView(dish: dish, tags: tags, language: language)
.frame(width: 200)
.opacity(0.8)
}
}
if filteredDishes.isEmpty {
Text("home_my_dishes_search_empty")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.padding(.top, 8)
}
}
.padding(.horizontal, 16)
}
}
.padding(.top, 16)
}
}
struct DishCardView: View {
let dish: Dish
let tags: [Tag]
let language: AppLanguage
var onEdit: (() -> Void)? = nil
var onDelete: (() -> Void)? = nil
private var dishTags: [Tag] {
tags.filter { dish.tagIds.contains($0.id) }
}
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(dish.name)
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
if let desc = dish.descriptionText, !desc.isEmpty {
Text(desc)
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
.lineLimit(1)
}
}
Spacer()
HStack(spacing: 4) {
ForEach(dishTags.prefix(3)) { tag in
TagDot(color: tag.color, size: 10)
}
}
if onEdit != nil || onDelete != nil {
HStack(spacing: 8) {
if let onEdit {
Button(action: onEdit) {
Image(systemName: "pencil.circle")
.foregroundColor(.mealMoodCoral)
}
.buttonStyle(.plain)
}
if let onDelete {
Button(action: onDelete) {
Image(systemName: "trash.circle")
.foregroundColor(.mealMoodError)
}
.buttonStyle(.plain)
}
}
}
}
.padding(14)
.mealCardStyle()
}
}
+752
View File
@@ -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))"
}
}
+283
View File
@@ -0,0 +1,283 @@
import SwiftUI
struct WeekCalendarView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
let plan: WeekPlan
let settings: AppSettings
let dishes: [Dish]
let tags: [Tag]
@ObservedObject var viewModel: HomeViewModel
var onTapEmptySlot: ((MealSlot) -> Void)?
private var dayRange: ClosedRange<Int> {
settings.includeWeekends ? 0...6 : 0...4
}
private var days: [Int] {
Array(dayRange)
}
private var mealTypes: [MealType] {
switch settings.mealWindowsEnum {
case .dinnerOnly: return [.dinner]
case .lunchOnly: return [.lunch]
case .both: return [.lunch, .dinner]
}
}
var body: some View {
Group {
if horizontalSizeClass == .regular {
regularGridLayout
} else {
compactLayout
}
}
}
private var compactLayout: some View {
ScrollView(.horizontal, showsIndicators: false) {
VStack(spacing: 0) {
HStack(spacing: 0) {
HStack(spacing: 0) {
ForEach(Array(days.enumerated()), id: \.element) { index, day in
dayHeaderCell(day: day, width: 96, height: 24)
if index < days.count - 1 {
calendarDivider
}
}
}
}
calendarHorizontalDivider
ForEach(Array(mealTypes.enumerated()), id: \.element) { index, mealType in
HStack(spacing: 0) {
ForEach(Array(days.enumerated()), id: \.element) { index, day in
let slot = slotFor(day: day, mealType: mealType)
draggableSlotView(slot: slot, mealType: mealType)
.frame(width: 96)
if index < days.count - 1 {
calendarDivider
}
}
}
if index < mealTypes.count - 1 {
calendarHorizontalDivider
}
}
}
.padding(.horizontal, 16)
}
}
private var regularGridLayout: some View {
GeometryReader { proxy in
let dayCount = CGFloat(dayRange.count)
let spacing: CGFloat = 10
let rowLabelWidth: CGFloat = 86
let contentWidth = proxy.size.width - rowLabelWidth - (dayCount * spacing)
let dayWidth = max(92, contentWidth / max(dayCount, 1))
VStack(spacing: 0) {
HStack(spacing: 0) {
Color.clear.frame(width: rowLabelWidth, height: 24)
ForEach(Array(days.enumerated()), id: \.element) { index, day in
dayHeaderCell(day: day, width: dayWidth, height: 24)
if index < days.count - 1 {
calendarDivider
.padding(.horizontal, spacing / 2)
}
}
}
calendarHorizontalDivider
ForEach(Array(mealTypes.enumerated()), id: \.element) { rowIndex, mealType in
HStack(spacing: 0) {
mealTypeLabel(mealType)
.frame(width: rowLabelWidth)
.padding(.trailing, spacing)
ForEach(Array(days.enumerated()), id: \.element) { index, day in
let slot = slotFor(day: day, mealType: mealType)
draggableSlotView(slot: slot, mealType: mealType)
.frame(width: dayWidth)
if index < days.count - 1 {
calendarDivider
.padding(.horizontal, spacing / 2)
}
}
}
if rowIndex < mealTypes.count - 1 {
calendarHorizontalDivider
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
}
.frame(minHeight: CGFloat(mealTypes.count) * 142 + 54)
}
private var calendarDivider: some View {
Rectangle()
.fill(Color.mealMoodTextSecondary.opacity(0.22))
.frame(width: 1)
}
private var calendarHorizontalDivider: some View {
Rectangle()
.fill(Color.mealMoodTextSecondary.opacity(0.22))
.frame(height: 1)
}
private func dayHeader(day: Int) -> some View {
HStack(spacing: 4) {
Text(LocalizedStringKey(dayKey(for: day)))
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextPrimary)
.fontWeight(.semibold)
let dayDate = plan.weekStartDate.addingDays(day)
Text("\(Calendar.current.component(.day, from: dayDate))")
.font(.mealMoodSmall)
.foregroundColor(dayDate.isToday ? .mealMoodCoral : .mealMoodTextPrimary)
.fontWeight(dayDate.isToday ? .bold : .regular)
}
}
private func dayHeaderCell(day: Int, width: CGFloat, height: CGFloat) -> some View {
dayHeader(day: day)
.frame(width: width, height: height)
.background(Color.mealMoodCoral.opacity(0.28))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.mealMoodCoral.opacity(0.45), lineWidth: 1)
)
.cornerRadius(8)
}
private func mealTypeLabel(_ mealType: MealType) -> some View {
VStack(spacing: 6) {
Image(systemName: mealType.icon)
.font(.system(size: 16, weight: .semibold))
.foregroundColor(.mealMoodCoral)
Text(LocalizedStringKey(mealType.rawValue))
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
.padding(.vertical, 8)
.background(Color.mealMoodSurface)
.cornerRadius(10)
}
private func slotFor(day: Int, mealType: MealType) -> MealSlot? {
plan.slots.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue }
}
private func draggableSlotView(slot: MealSlot?, mealType: MealType) -> some View {
slotView(slot: slot, mealType: mealType)
.dropDestination(for: String.self) { items, _ in
guard let payloadRaw = items.first,
let payload = parseDropPayload(payloadRaw),
let targetSlot = slot,
viewModel.canEditCurrentWeek else { return false }
switch payload {
case .dish(let dishId):
guard let dish = dishes.first(where: { $0.id == dishId }) else { return false }
let isValid = AutocompleteEngine.validateDrop(
dish: dish, slot: targetSlot, plan: plan,
allTags: tags, allDishes: dishes
)
if isValid {
viewModel.assignDish(dish, to: targetSlot, plan: plan, settings: settings)
return true
}
viewModel.confirmInvalidDrop(dish, to: targetSlot, plan: plan, settings: settings)
return true
case .slot(let sourceSlotId):
guard let sourceSlot = plan.slots.first(where: { $0.id == sourceSlotId }) else { return false }
viewModel.moveOrSwapDish(
from: sourceSlot,
to: targetSlot,
plan: plan,
settings: settings,
allTags: tags,
allDishes: dishes
)
return true
}
}
}
@ViewBuilder
private func slotView(slot: MealSlot?, mealType: MealType) -> some View {
if let slot = slot, let dishId = slot.dishId,
let dish = dishes.first(where: { $0.id == dishId }) {
let dishTags = tags.filter { dish.tagIds.contains($0.id) }
FilledSlotView(
dishName: dish.name,
dishDescription: dish.descriptionText,
tags: dishTags.map { (name: $0.localizedName(language: settings.languageEnum.resolved()), color: $0.color) },
mealType: mealType,
showsRuleWarning: slot.isRuleOverridden,
onRemove: viewModel.canEditCurrentWeek ? {
viewModel.removeDish(from: slot, plan: plan, settings: settings)
} : nil
)
.draggable("slot:\(slot.id.uuidString)")
} else if let slot = slot {
EmptySlotView(mealType: mealType)
.contentShape(Rectangle())
.onTapGesture {
guard viewModel.canEditCurrentWeek else { return }
onTapEmptySlot?(slot)
}
} else {
EmptySlotView(mealType: mealType)
}
}
private enum DropPayload {
case dish(UUID)
case slot(UUID)
}
private func parseDropPayload(_ raw: String) -> DropPayload? {
let parts = raw.split(separator: ":", maxSplits: 1).map(String.init)
guard parts.count == 2, let id = UUID(uuidString: parts[1]) else { return nil }
switch parts[0] {
case "dish":
return .dish(id)
case "slot":
return .slot(id)
default:
// Backward compatibility for older draggable payloads that only sent dish UUID.
if let fallbackDishId = UUID(uuidString: raw) {
return .dish(fallbackDishId)
}
return nil
}
}
private func dayKey(for dayIndex: Int) -> String {
switch dayIndex {
case 0: return "day_mon"
case 1: return "day_tue"
case 2: return "day_wed"
case 3: return "day_thu"
case 4: return "day_fri"
case 5: return "day_sat"
case 6: return "day_sun"
default: return ""
}
}
}
+136
View File
@@ -0,0 +1,136 @@
import SwiftUI
struct WeekPlanShareView: View {
let plan: WeekPlan
let settings: AppSettings
let dishes: [Dish]
let tags: [Tag]
private var dayRange: ClosedRange<Int> {
settings.includeWeekends ? 0...6 : 0...4
}
private var mealTypes: [MealType] {
switch settings.mealWindowsEnum {
case .dinnerOnly: return [.dinner]
case .lunchOnly: return [.lunch]
case .both: return [.lunch, .dinner]
}
}
var body: some View {
ZStack {
LinearGradient(
colors: [
Color(hex: "#FFF6F0"),
Color(hex: "#F6FCFA")
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
VStack(alignment: .leading, spacing: 28) {
HStack(spacing: 16) {
AppIconPlaceholder(size: 72)
VStack(alignment: .leading, spacing: 6) {
Text("MealMood")
.font(.system(size: 44, weight: .bold))
.foregroundColor(.mealMoodTextPrimary)
Text(plan.weekStartDate.formattedWeekRange())
.font(.system(size: 26, weight: .medium))
.foregroundColor(.mealMoodTextSecondary)
}
Spacer()
Text(Date.now.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 20, weight: .medium))
.foregroundColor(.mealMoodTextSecondary)
}
Grid(horizontalSpacing: 10, verticalSpacing: 10) {
GridRow {
gridHeaderCell("")
.frame(minWidth: 220)
ForEach(Array(dayRange), id: \.self) { day in
gridHeaderCell(dayTitle(for: day))
}
}
ForEach(mealTypes, id: \.self) { mealType in
GridRow {
gridMealCell(mealTypeLabel(mealType), icon: mealType.icon)
.frame(minWidth: 220)
ForEach(Array(dayRange), id: \.self) { day in
gridDishCell(dishName(day: day, mealType: mealType))
}
}
}
}
Spacer()
Text("share_week_title")
.font(.system(size: 18, weight: .medium))
.foregroundColor(.mealMoodTextSecondary)
}
.padding(56)
}
.frame(width: 2400, height: 1700)
}
private func dayTitle(for day: Int) -> String {
let keys = ["day_mon", "day_tue", "day_wed", "day_thu", "day_fri", "day_sat", "day_sun"]
let localizedDay = day >= 0 && day < keys.count ? NSLocalizedString(keys[day], comment: "") : ""
let dayDate = plan.weekStartDate.addingDays(day)
let dayNumber = Calendar.current.component(.day, from: dayDate)
return "\(localizedDay) \(dayNumber)"
}
private func mealTypeLabel(_ mealType: MealType) -> String {
mealType == .lunch ? String(localized: "lunch") : String(localized: "dinner")
}
private func dishName(day: Int, mealType: MealType) -> String {
let slot = plan.slots.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue }
guard let slot, let dishId = slot.dishId, let dish = dishes.first(where: { $0.id == dishId }) else {
return ""
}
return dish.name
}
private func gridHeaderCell(_ title: String) -> some View {
Text(title)
.font(.system(size: 24, weight: .semibold))
.foregroundColor(.mealMoodTextPrimary)
.frame(maxWidth: .infinity, minHeight: 92)
.padding(.horizontal, 10)
.background(Color.white.opacity(0.95))
.clipShape(RoundedRectangle(cornerRadius: 14))
}
private func gridMealCell(_ title: String, icon: String) -> some View {
HStack(spacing: 8) {
Image(systemName: icon)
Text(title)
}
.font(.system(size: 24, weight: .semibold))
.foregroundColor(.mealMoodTextPrimary)
.frame(maxWidth: .infinity, minHeight: 130)
.padding(.horizontal, 14)
.background(Color.white.opacity(0.95))
.clipShape(RoundedRectangle(cornerRadius: 14))
}
private func gridDishCell(_ title: String) -> some View {
Text(title)
.font(.system(size: 26, weight: .medium))
.foregroundColor(.mealMoodTextPrimary)
.multilineTextAlignment(.center)
.lineLimit(3)
.minimumScaleFactor(0.6)
.frame(maxWidth: .infinity, minHeight: 130)
.padding(.horizontal, 10)
.background(Color.white.opacity(0.92))
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}