Files
FamilyMealPlanner/MealMood/Views/Dishes/DishListView.swift
T
alexandrev-tibco b45cb2a530 1.1.5: conversion funnel overhaul
Paywall:
- Redesigned PremiumView with yearly/monthly/lifetime side by side
- 7-day free trial badge with savings % vs monthly
- Social proof line, BEST VALUE highlight on yearly
- StoreManager: new yearlyProduct, lifetimeProduct, yearlySavingsPercent, hasTrialAvailable

Funnel:
- Paywall step added to onboarding (PaywallStepView)
- Contextual modal at dish limit (replaces silent block)
- Visible "X/15 dishes" counter in DishListView
- BottomPromoBanner rotates AdMob with internal "Remove ads" CTA (1 in 4)
- Free dish limit lowered 20 → 15
- Review prompt earlier: 2 weeks → 1 week

Analytics:
- paywall_viewed/dismissed with source + seconds_on_screen
- dish_limit_hit, week_limit_hit, free_trial_started events
- Every PremiumView call site now passes its source

StoreKit:
- MealMood.storekit: added yearly $19.99 + 7d trial, monthly trial, lifetime $39.99
- AppStoreConnect-1.1.5-setup.md with full manual setup instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:21:36 +02:00

161 lines
6.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 showPremium = false
private var language: AppLanguage {
(allSettings.first?.languageEnum ?? .system).resolved()
}
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: { showDishForm = true })
.padding(.horizontal, 60)
}
} else {
VStack(spacing: 0) {
if let settings = allSettings.first, !settings.isPremium {
dishCounterBanner
}
List {
ForEach(dishes, 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 {
showDishForm = true
} label: {
Image(systemName: "plus")
.foregroundColor(.mealMoodCoral)
}
}
}
.sheet(isPresented: $showPremium) {
if let settings = allSettings.first {
NavigationStack { PremiumView(settings: settings, source: "dish_counter") }
}
}
.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 {
showPremium = true
} 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)
}
private func deleteDishes(at offsets: IndexSet) {
let currentWeekStart = Date().startOfWeek()
let currentWeekPlan = weekPlans.first { $0.weekStartDate == currentWeekStart }
for index in offsets {
let dish = dishes[index]
if currentWeekPlan?.slots.contains(where: { $0.dishId == dish.id }) == true {
showDeleteBlockedAlert = true
continue
}
context.delete(dish)
}
try? context.save()
}
}