platos: paywall inmediato al llegar al limite y orden alfabetico

- 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
This commit is contained in:
alexandrev-tibco
2026-09-01 11:18:34 +02:00
parent f51f975941
commit 47daab9e51
10 changed files with 97 additions and 17 deletions
@@ -421,3 +421,4 @@
"snack" = "Snack"; "snack" = "Snack";
"settings_show_dish_photos" = "Gerichtfotos im Planer anzeigen"; "settings_show_dish_photos" = "Gerichtfotos im Planer anzeigen";
"dish_sort_alphabetical" = "Alphabetisch sortieren";
@@ -421,3 +421,4 @@
"snack" = "Snack"; "snack" = "Snack";
"settings_show_dish_photos" = "Show dish photos in planner"; "settings_show_dish_photos" = "Show dish photos in planner";
"dish_sort_alphabetical" = "Sort alphabetically";
@@ -421,3 +421,4 @@
"snack" = "Merienda"; "snack" = "Merienda";
"settings_show_dish_photos" = "Mostrar fotos de platos en el planificador"; "settings_show_dish_photos" = "Mostrar fotos de platos en el planificador";
"dish_sort_alphabetical" = "Ordenar alfabéticamente";
@@ -421,3 +421,4 @@
"snack" = "Goûter"; "snack" = "Goûter";
"settings_show_dish_photos" = "Afficher les photos des plats dans le planning"; "settings_show_dish_photos" = "Afficher les photos des plats dans le planning";
"dish_sort_alphabetical" = "Trier par ordre alphabétique";
@@ -421,3 +421,4 @@
"snack" = "Merenda"; "snack" = "Merenda";
"settings_show_dish_photos" = "Mostra le foto dei piatti nel planner"; "settings_show_dish_photos" = "Mostra le foto dei piatti nel planner";
"dish_sort_alphabetical" = "Ordina alfabeticamente";
@@ -421,3 +421,4 @@
"snack" = "Lanche"; "snack" = "Lanche";
"settings_show_dish_photos" = "Mostrar fotos dos pratos no planejador"; "settings_show_dish_photos" = "Mostrar fotos dos pratos no planejador";
"dish_sort_alphabetical" = "Ordenar alfabeticamente";
+1 -1
View File
@@ -515,7 +515,7 @@ struct DishFormView: View {
} }
// Simple flow layout for tags // Simple flow layout for tags
private struct DishLimitModal: View { struct DishLimitModal: View {
let onSeePremium: () -> Void let onSeePremium: () -> Void
let onDismiss: () -> Void let onDismiss: () -> Void
+51 -8
View File
@@ -10,12 +10,19 @@ struct DishListView: View {
@State private var showDishForm = false @State private var showDishForm = false
@State private var editingDish: Dish? @State private var editingDish: Dish?
@State private var showDeleteBlockedAlert = false @State private var showDeleteBlockedAlert = false
@State private var showPremium = false @State private var showDishLimitModal = false
@State private var paywallPresentation: PaywallPresentation?
@AppStorage("dish_list_alphabetical_sort") private var alphabeticalSort = false
private var language: AppLanguage { private var language: AppLanguage {
(allSettings.first?.languageEnum ?? .system).resolved() (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 { var body: some View {
ZStack { ZStack {
Color.mealMoodBackground.ignoresSafeArea() Color.mealMoodBackground.ignoresSafeArea()
@@ -28,7 +35,7 @@ struct DishListView: View {
Text("dish_list_empty") Text("dish_list_empty")
.font(.mealMoodBody) .font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary) .foregroundColor(.mealMoodTextSecondary)
PrimaryButton(title: String(localized: "home_add_dish"), action: { showDishForm = true }) PrimaryButton(title: String(localized: "home_add_dish"), action: attemptAddDish)
.padding(.horizontal, 60) .padding(.horizontal, 60)
} }
} else { } else {
@@ -37,7 +44,7 @@ struct DishListView: View {
dishCounterBanner dishCounterBanner
} }
List { List {
ForEach(dishes, id: \.id) { dish in ForEach(displayedDishes, id: \.id) { dish in
let dishTags = tags.filter { dish.tagIds.contains($0.id) } let dishTags = tags.filter { dish.tagIds.contains($0.id) }
Button { Button {
editingDish = dish editingDish = dish
@@ -77,18 +84,40 @@ struct DishListView: View {
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button { Button {
showDishForm = true 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: { } label: {
Image(systemName: "plus") Image(systemName: "plus")
.foregroundColor(.mealMoodCoral) .foregroundColor(.mealMoodCoral)
} }
} }
} }
.sheet(isPresented: $showPremium) { .sheet(item: $paywallPresentation) { presentation in
if let settings = allSettings.first { if let settings = allSettings.first {
NavigationStack { PremiumView(settings: settings, source: "dish_counter") } 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) { .sheet(isPresented: $showDishForm) {
DishFormView() DishFormView()
} }
@@ -110,7 +139,7 @@ struct DishListView: View {
let accent: Color = isNearLimit ? .mealMoodCoral : .mealMoodTextSecondary let accent: Color = isNearLimit ? .mealMoodCoral : .mealMoodTextSecondary
return Button { return Button {
showPremium = true paywallPresentation = PaywallPresentation(source: "dish_counter")
} label: { } label: {
HStack(spacing: 10) { HStack(spacing: 10) {
Image(systemName: isNearLimit ? "exclamationmark.circle.fill" : "star.circle") Image(systemName: isNearLimit ? "exclamationmark.circle.fill" : "star.circle")
@@ -143,12 +172,26 @@ struct DishListView: View {
.padding(.bottom, 4) .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) { private func deleteDishes(at offsets: IndexSet) {
let currentWeekStart = Date().startOfWeek() let currentWeekStart = Date().startOfWeek()
let currentWeekPlan = weekPlans.first { $0.weekStartDate == currentWeekStart } let currentWeekPlan = weekPlans.first { $0.weekStartDate == currentWeekStart }
for index in offsets { for index in offsets {
let dish = dishes[index] let dish = displayedDishes[index]
if currentWeekPlan?.slotList.contains(where: { $0.dishId == dish.id }) == true { if currentWeekPlan?.slotList.contains(where: { $0.dishId == dish.id }) == true {
showDeleteBlockedAlert = true showDeleteBlockedAlert = true
continue continue
+31 -8
View File
@@ -16,6 +16,7 @@ struct HomeView: View {
// separate @State vars the sheet sometimes rendered before the source was // separate @State vars the sheet sometimes rendered before the source was
// set and logged paywall_viewed as "unknown". // set and logged paywall_viewed as "unknown".
@State private var paywallPresentation: PaywallPresentation? @State private var paywallPresentation: PaywallPresentation?
@State private var showDishLimitModal: Bool = false
@State private var showMonthlyHistory: Bool = false @State private var showMonthlyHistory: Bool = false
@State private var showStats: Bool = false @State private var showStats: Bool = false
@State private var showWeekLimitUpsell: Bool = false @State private var showWeekLimitUpsell: Bool = false
@@ -451,6 +452,19 @@ struct HomeView: View {
.sheet(isPresented: $viewModel.showDishForm) { .sheet(isPresented: $viewModel.showDishForm) {
DishFormView() DishFormView()
} }
.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: $showWeekPicker) { .sheet(isPresented: $showWeekPicker) {
NavigationStack { NavigationStack {
Form { Form {
@@ -516,7 +530,7 @@ struct HomeView: View {
onCreateDish: { onCreateDish: {
selectedEmptySlotId = nil selectedEmptySlotId = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
viewModel.showDishForm = true attemptCreateDish()
} }
}, },
onEatingOut: { onEatingOut: {
@@ -554,7 +568,7 @@ struct HomeView: View {
onCreateDish: { onCreateDish: {
selectedFilledSlotId = nil selectedFilledSlotId = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
viewModel.showDishForm = true attemptCreateDish()
} }
} }
) )
@@ -653,11 +667,6 @@ struct HomeView: View {
} }
} }
private struct PaywallPresentation: Identifiable {
let id = UUID()
let source: String
}
private struct SlotDishPickerSheet: View { private struct SlotDishPickerSheet: View {
let dishes: [Dish] let dishes: [Dish]
let tags: [Tag] let tags: [Tag]
@@ -804,7 +813,7 @@ struct HomeView: View {
language: settings.languageEnum.resolved(), language: settings.languageEnum.resolved(),
usedDishIds: Set(plan.slotList.compactMap(\.dishId)), usedDishIds: Set(plan.slotList.compactMap(\.dishId)),
usageRanking: dishUsageCounts, usageRanking: dishUsageCounts,
onAddDish: { viewModel.showDishForm = true }, onAddDish: { attemptCreateDish() },
onQuickAssignDish: { dish in onQuickAssignDish: { dish in
viewModel.assignDishToFirstFreeSlot( viewModel.assignDishToFirstFreeSlot(
dish, dish,
@@ -1183,6 +1192,20 @@ struct HomeView: View {
AnalyticsService.logEvent("widget_promo_dismissed") AnalyticsService.logEvent("widget_promo_dismissed")
} }
/// Gate every "create dish" entry point on the free limit the check used
/// to live only in the form's save button, so a user at the limit could
/// open and fill the whole form before hitting the paywall.
private func attemptCreateDish() {
let isPremium = allSettings.first?.isPremium ?? false
if PremiumAccess.hasReachedFreeDishLimit(dishCount: dishes.count, isPremium: isPremium) {
AnalyticsService.logDishLimitHit()
HapticManager.shared.notification(type: .warning)
showDishLimitModal = true
} else {
viewModel.showDishForm = true
}
}
private func evaluateReviewPrompt() { private func evaluateReviewPrompt() {
let descriptor = FetchDescriptor<WeekPlan>() let descriptor = FetchDescriptor<WeekPlan>()
guard let plans = try? context.fetch(descriptor) else { return } guard let plans = try? context.fetch(descriptor) else { return }
+8
View File
@@ -1,6 +1,14 @@
import SwiftUI import SwiftUI
import StoreKit import StoreKit
/// Wrapper for presenting the paywall via `sheet(item:)` so the attribution
/// source travels with the presentation (two separate @State vars sometimes
/// rendered the sheet before the source was set GA4 logged "unknown").
struct PaywallPresentation: Identifiable {
let id = UUID()
let source: String
}
struct PremiumView: View { struct PremiumView: View {
@Environment(\.modelContext) private var context @Environment(\.modelContext) private var context
@StateObject private var storeManager = StoreManager() @StateObject private var storeManager = StoreManager()