47daab9e51
- attemptAddDish/attemptCreateDish: el limite gratuito se comprueba en el tap de anadir plato (DishListView y los 3 puntos de Home) y muestra el DishLimitModal -> paywall directamente; antes solo se comprobaba al guardar el formulario y el usuario podia rellenarlo entero para nada - DishLimitModal compartido (era private de DishFormView) y PaywallPresentation promovido a PremiumView.swift; DishListView usa sheet(item:) tambien para el banner (source dish_counter vs dish_limit) - Boton en la toolbar de Mis Platos que alterna orden alfabetico (persistido en dish_list_alphabetical_sort); borrado por swipe corregido para usar la lista mostrada Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
204 lines
8.4 KiB
Swift
204 lines
8.4 KiB
Swift
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()
|
|
}
|
|
}
|