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:
@@ -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";
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user