import SwiftUI import SwiftData struct DishListView: View { @Environment(\.modelContext) private var context @Query(sort: \Dish.createdAt, order: .reverse) private var dishes: [Dish] @Query private var tags: [Tag] @Query private var allSettings: [AppSettings] @Query private var weekPlans: [WeekPlan] @State private var showDishForm = false @State private var editingDish: Dish? @State private var showDeleteBlockedAlert = false @State private var showDishLimitModal = false @State private var paywallPresentation: PaywallPresentation? @AppStorage("dish_list_alphabetical_sort") private var alphabeticalSort = false private var language: AppLanguage { (allSettings.first?.languageEnum ?? .system).resolved() } private var displayedDishes: [Dish] { guard alphabeticalSort else { return dishes } return dishes.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } var body: some View { ZStack { Color.mealMoodBackground.ignoresSafeArea() if dishes.isEmpty { VStack(spacing: 16) { Image(systemName: "fork.knife") .font(.system(size: 50)) .foregroundColor(Color(hex: "#C4C4C4")) Text("dish_list_empty") .font(.mealMoodBody) .foregroundColor(.mealMoodTextSecondary) PrimaryButton(title: String(localized: "home_add_dish"), action: attemptAddDish) .padding(.horizontal, 60) } } else { VStack(spacing: 0) { if let settings = allSettings.first, !settings.isPremium { dishCounterBanner } List { ForEach(displayedDishes, id: \.id) { dish in let dishTags = tags.filter { dish.tagIds.contains($0.id) } Button { editingDish = dish } label: { 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(2)) { tag in TagPill(name: tag.localizedName(language: language), color: tag.color) } } Image(systemName: "chevron.right") .font(.system(size: 12)) .foregroundColor(.mealMoodTextSecondary) } } .listRowBackground(Color.mealMoodSurface) } .onDelete(perform: deleteDishes) } .scrollContentBackground(.hidden) } } } .navigationTitle("home_my_dishes") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { withAnimation { alphabeticalSort.toggle() } } label: { Image(systemName: "arrow.up.arrow.down") .foregroundColor(alphabeticalSort ? .mealMoodCoral : .mealMoodTextSecondary) } .accessibilityLabel(Text("dish_sort_alphabetical")) } ToolbarItem(placement: .navigationBarTrailing) { Button { attemptAddDish() } label: { Image(systemName: "plus") .foregroundColor(.mealMoodCoral) } } } .sheet(item: $paywallPresentation) { presentation in if let settings = allSettings.first { NavigationStack { PremiumView(settings: settings, source: presentation.source) } } } .sheet(isPresented: $showDishLimitModal) { DishLimitModal( onSeePremium: { showDishLimitModal = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { paywallPresentation = PaywallPresentation(source: "dish_limit") } }, onDismiss: { showDishLimitModal = false } ) .presentationDetents([.medium]) .presentationDragIndicator(.visible) } .sheet(isPresented: $showDishForm) { DishFormView() } .sheet(item: $editingDish) { dish in DishFormView(dish: dish) } .alert("dish_delete_blocked_title", isPresented: $showDeleteBlockedAlert) { Button("dish_delete_blocked_ok", role: .cancel) {} } message: { Text("dish_delete_blocked_message") } } private var dishCounterBanner: some View { let count = dishes.count let limit = PremiumAccess.freeDishLimit let remaining = max(0, limit - count) let isNearLimit = remaining <= 2 let accent: Color = isNearLimit ? .mealMoodCoral : .mealMoodTextSecondary return Button { paywallPresentation = PaywallPresentation(source: "dish_counter") } label: { HStack(spacing: 10) { Image(systemName: isNearLimit ? "exclamationmark.circle.fill" : "star.circle") .foregroundColor(accent) VStack(alignment: .leading, spacing: 2) { Text("\(count) / \(limit) \(String(localized: "dish_counter_label"))") .font(.mealMoodSmall.weight(.semibold)) .foregroundColor(.mealMoodTextPrimary) Text("dish_counter_upgrade_hint") .font(.mealMoodCaption) .foregroundColor(.mealMoodTextSecondary) } Spacer() Image(systemName: "chevron.right") .font(.system(size: 12)) .foregroundColor(.mealMoodTextSecondary) } .padding(.horizontal, 14) .padding(.vertical, 10) .background(Color.mealMoodSurface) .clipShape(RoundedRectangle(cornerRadius: 12)) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(accent.opacity(isNearLimit ? 0.5 : 0.15), lineWidth: 1) ) } .buttonStyle(.plain) .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 4) } /// Same gate as the form's save button, but at the entry point: a user at /// the free limit gets the paywall on tapping "add", not after filling in /// a dish they can't save. private func attemptAddDish() { let isPremium = allSettings.first?.isPremium ?? false if PremiumAccess.hasReachedFreeDishLimit(dishCount: dishes.count, isPremium: isPremium) { AnalyticsService.logDishLimitHit() HapticManager.shared.notification(type: .warning) showDishLimitModal = true } else { showDishForm = true } } private func deleteDishes(at offsets: IndexSet) { let currentWeekStart = Date().startOfWeek() let currentWeekPlan = weekPlans.first { $0.weekStartDate == currentWeekStart } for index in offsets { let dish = displayedDishes[index] if currentWeekPlan?.slotList.contains(where: { $0.dishId == dish.id }) == true { showDeleteBlockedAlert = true continue } context.delete(dish) } try? context.save() } }