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>
This commit is contained in:
alexandrev-tibco
2026-06-11 22:21:36 +02:00
parent 72770801d3
commit b45cb2a530
31 changed files with 1067 additions and 104 deletions
+12 -4
View File
@@ -61,6 +61,7 @@
ACAEFA5F4C170C6438CD2652 /* TagLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B976BF8B0BD6D1AA5F8C68 /* TagLocalizationTests.swift */; }; ACAEFA5F4C170C6438CD2652 /* TagLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B976BF8B0BD6D1AA5F8C68 /* TagLocalizationTests.swift */; };
AD5F8FDE30CE4B2934B3C1C1 /* WeekPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = A23267BAA116E1214870416C /* WeekPlan.swift */; }; AD5F8FDE30CE4B2934B3C1C1 /* WeekPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = A23267BAA116E1214870416C /* WeekPlan.swift */; };
B3C9E5149B70C477EACBDFFB /* AdBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050EA827CA951AB9E847E2CA /* AdBannerView.swift */; }; B3C9E5149B70C477EACBDFFB /* AdBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050EA827CA951AB9E847E2CA /* AdBannerView.swift */; };
C7B1F5A21B5E4A9E91B7F5A2 /* BottomPromoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E2F6A3F47E5C9F92A8E6B3 /* BottomPromoBanner.swift */; };
B8D7CA62789429C2F693E4F6 /* MealMoodLogoMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC64110469AA91D5F28C423 /* MealMoodLogoMark.swift */; }; B8D7CA62789429C2F693E4F6 /* MealMoodLogoMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC64110469AA91D5F28C423 /* MealMoodLogoMark.swift */; };
BE908B23F64F33D594AB8D82 /* DateHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD33453B7DF86EDBAEB80F1 /* DateHelpersTests.swift */; }; BE908B23F64F33D594AB8D82 /* DateHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD33453B7DF86EDBAEB80F1 /* DateHelpersTests.swift */; };
C14BDE4C3EF6AF75F378D892 /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA97AD958C922D8AFF73314 /* PrimaryButton.swift */; }; C14BDE4C3EF6AF75F378D892 /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA97AD958C922D8AFF73314 /* PrimaryButton.swift */; };
@@ -73,6 +74,7 @@
D8149D55E74F7BBFC0402F46 /* DishFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63CA2A8666E1FBBAD65F141 /* DishFormView.swift */; }; D8149D55E74F7BBFC0402F46 /* DishFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63CA2A8666E1FBBAD65F141 /* DishFormView.swift */; };
D89413095E37C3D677856437 /* PremiumAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0200C0C11C912711C724558 /* PremiumAccess.swift */; }; D89413095E37C3D677856437 /* PremiumAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0200C0C11C912711C724558 /* PremiumAccess.swift */; };
DE43F08AAC939C083BF511C1 /* FirstDishesStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10594EA55F6BEFF4241D364C /* FirstDishesStepView.swift */; }; DE43F08AAC939C083BF511C1 /* FirstDishesStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10594EA55F6BEFF4241D364C /* FirstDishesStepView.swift */; };
E5D9C4B783A14E2B9F4D2E68 /* PaywallStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F61A8C92B47D5938C61E9A75 /* PaywallStepView.swift */; };
DE797C90DFDAFAC5C12E3D97 /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFBCD4FFE0D067A591104B74 /* ToastView.swift */; }; DE797C90DFDAFAC5C12E3D97 /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFBCD4FFE0D067A591104B74 /* ToastView.swift */; };
E12A5EE3D940260902277CC5 /* WeekCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B309A43E486B5A723324BA /* WeekCalendarView.swift */; }; E12A5EE3D940260902277CC5 /* WeekCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B309A43E486B5A723324BA /* WeekCalendarView.swift */; };
E2524335ED3A5DB7813BEA99 /* WelcomeStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */; }; E2524335ED3A5DB7813BEA99 /* WelcomeStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */; };
@@ -119,11 +121,13 @@
035C6C0B728EB0EA80394CDD /* WeekendsStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekendsStepView.swift; sourceTree = "<group>"; }; 035C6C0B728EB0EA80394CDD /* WeekendsStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekendsStepView.swift; sourceTree = "<group>"; };
049AE68D5B577514ADD6BACB /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; }; 049AE68D5B577514ADD6BACB /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
050EA827CA951AB9E847E2CA /* AdBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdBannerView.swift; sourceTree = "<group>"; }; 050EA827CA951AB9E847E2CA /* AdBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdBannerView.swift; sourceTree = "<group>"; };
D8E2F6A3F47E5C9F92A8E6B3 /* BottomPromoBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomPromoBanner.swift; sourceTree = "<group>"; };
065982B284538632951F1C3A /* MealMoodLogo.svg */ = {isa = PBXFileReference; lastKnownFileType = text; path = MealMoodLogo.svg; sourceTree = "<group>"; }; 065982B284538632951F1C3A /* MealMoodLogo.svg */ = {isa = PBXFileReference; lastKnownFileType = text; path = MealMoodLogo.svg; sourceTree = "<group>"; };
0EC555FD2F4498780005513D /* aaaa.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = aaaa.storekit; sourceTree = "<group>"; }; 0EC555FD2F4498780005513D /* aaaa.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = aaaa.storekit; sourceTree = "<group>"; };
0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
103374A7D91B4F884278DA72 /* StoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = "<group>"; }; 103374A7D91B4F884278DA72 /* StoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = "<group>"; };
10594EA55F6BEFF4241D364C /* FirstDishesStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstDishesStepView.swift; sourceTree = "<group>"; }; 10594EA55F6BEFF4241D364C /* FirstDishesStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstDishesStepView.swift; sourceTree = "<group>"; };
F61A8C92B47D5938C61E9A75 /* PaywallStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallStepView.swift; sourceTree = "<group>"; };
13DB00DA865F6FE86952BFC3 /* TagListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagListView.swift; sourceTree = "<group>"; }; 13DB00DA865F6FE86952BFC3 /* TagListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagListView.swift; sourceTree = "<group>"; };
168A6CA95246E1BB72797D4E /* OnboardingViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewModelTests.swift; sourceTree = "<group>"; }; 168A6CA95246E1BB72797D4E /* OnboardingViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewModelTests.swift; sourceTree = "<group>"; };
17B976BF8B0BD6D1AA5F8C68 /* TagLocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagLocalizationTests.swift; sourceTree = "<group>"; }; 17B976BF8B0BD6D1AA5F8C68 /* TagLocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagLocalizationTests.swift; sourceTree = "<group>"; };
@@ -430,6 +434,7 @@
10594EA55F6BEFF4241D364C /* FirstDishesStepView.swift */, 10594EA55F6BEFF4241D364C /* FirstDishesStepView.swift */,
9FBA11C8A81D5C0CA8A3C13D /* MealWindowsStepView.swift */, 9FBA11C8A81D5C0CA8A3C13D /* MealWindowsStepView.swift */,
6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */, 6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */,
F61A8C92B47D5938C61E9A75 /* PaywallStepView.swift */,
035C6C0B728EB0EA80394CDD /* WeekendsStepView.swift */, 035C6C0B728EB0EA80394CDD /* WeekendsStepView.swift */,
70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */, 70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */,
); );
@@ -441,6 +446,7 @@
children = ( children = (
050EA827CA951AB9E847E2CA /* AdBannerView.swift */, 050EA827CA951AB9E847E2CA /* AdBannerView.swift */,
00F2EE5A756C183B6D505403 /* AppIconPlaceholder.swift */, 00F2EE5A756C183B6D505403 /* AppIconPlaceholder.swift */,
D8E2F6A3F47E5C9F92A8E6B3 /* BottomPromoBanner.swift */,
B68E5C82608F60FF8798BB81 /* EmptySlotView.swift */, B68E5C82608F60FF8798BB81 /* EmptySlotView.swift */,
FD4FD72583C004A5D5C939A3 /* FilledSlotView.swift */, FD4FD72583C004A5D5C939A3 /* FilledSlotView.swift */,
BAC64110469AA91D5F28C423 /* MealMoodLogoMark.swift */, BAC64110469AA91D5F28C423 /* MealMoodLogoMark.swift */,
@@ -642,6 +648,7 @@
files = ( files = (
B3C9E5149B70C477EACBDFFB /* AdBannerView.swift in Sources */, B3C9E5149B70C477EACBDFFB /* AdBannerView.swift in Sources */,
A9278F6559777CB9FA7FB69E /* AdMobConfig.swift in Sources */, A9278F6559777CB9FA7FB69E /* AdMobConfig.swift in Sources */,
C7B1F5A21B5E4A9E91B7F5A2 /* BottomPromoBanner.swift in Sources */,
FB0011111111111111111106 /* AnalyticsService.swift in Sources */, FB0011111111111111111106 /* AnalyticsService.swift in Sources */,
E6A57F29C02AE138D705BAF7 /* AppIconPlaceholder.swift in Sources */, E6A57F29C02AE138D705BAF7 /* AppIconPlaceholder.swift in Sources */,
D2840F9D92D16026338CE83C /* AppSettings.swift in Sources */, D2840F9D92D16026338CE83C /* AppSettings.swift in Sources */,
@@ -660,6 +667,7 @@
5ECA2501EC257777DBE670AD /* EmptySlotView.swift in Sources */, 5ECA2501EC257777DBE670AD /* EmptySlotView.swift in Sources */,
786E0F918F60E93A7EE2AD3E /* FilledSlotView.swift in Sources */, 786E0F918F60E93A7EE2AD3E /* FilledSlotView.swift in Sources */,
DE43F08AAC939C083BF511C1 /* FirstDishesStepView.swift in Sources */, DE43F08AAC939C083BF511C1 /* FirstDishesStepView.swift in Sources */,
E5D9C4B783A14E2B9F4D2E68 /* PaywallStepView.swift in Sources */,
5BCED5AFADACD4118AE59AF9 /* Font+MealMood.swift in Sources */, 5BCED5AFADACD4118AE59AF9 /* Font+MealMood.swift in Sources */,
AB76A63B140922D69F314C37 /* HapticManager.swift in Sources */, AB76A63B140922D69F314C37 /* HapticManager.swift in Sources */,
31DFC035AE34F49FB35071A7 /* HomeView.swift in Sources */, 31DFC035AE34F49FB35071A7 /* HomeView.swift in Sources */,
@@ -876,7 +884,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements; CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CURRENT_PROJECT_VERSION = 50; CURRENT_PROJECT_VERSION = 51;
DEVELOPMENT_TEAM = 2825Q76T7H; DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MealMood/Resources/Info.plist; INFOPLIST_FILE = MealMood/Resources/Info.plist;
@@ -885,7 +893,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.0; MARKETING_VERSION = 1.1.5;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood;
PRODUCT_NAME = MealMood; PRODUCT_NAME = MealMood;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -981,7 +989,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements; CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CURRENT_PROJECT_VERSION = 50; CURRENT_PROJECT_VERSION = 51;
DEVELOPMENT_TEAM = 2825Q76T7H; DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MealMood/Resources/Info.plist; INFOPLIST_FILE = MealMood/Resources/Info.plist;
@@ -990,7 +998,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.0; MARKETING_VERSION = 1.1.5;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood;
PRODUCT_NAME = MealMood; PRODUCT_NAME = MealMood;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -0,0 +1,70 @@
import SwiftUI
struct BottomPromoBanner: View {
let settings: AppSettings
@State private var showRemoveAdsPromo: Bool = false
@State private var showPremium = false
private static let counterKey = "com.mealmood.bottomBannerRotation"
private static let rotationInterval = 4
var body: some View {
Group {
if showRemoveAdsPromo {
removeAdsPromo
} else {
AdBannerView()
}
}
.onAppear(perform: decideRotation)
.sheet(isPresented: $showPremium) {
NavigationStack { PremiumView(settings: settings, source: "ad_replacement") }
}
}
private var removeAdsPromo: some View {
VStack(spacing: 0) {
Rectangle()
.fill(Color.mealMoodTextSecondary.opacity(0.22))
.frame(height: 1)
Button {
showPremium = true
} label: {
HStack(spacing: 10) {
Image(systemName: "star.fill")
.foregroundColor(.mealMoodCoral)
VStack(alignment: .leading, spacing: 1) {
Text("remove_ads_promo_title")
.font(.mealMoodSmall.weight(.semibold))
.foregroundColor(.mealMoodTextPrimary)
Text("remove_ads_promo_subtitle")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
Spacer()
Text("remove_ads_promo_cta")
.font(.mealMoodCaption.weight(.semibold))
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.mealMoodCoral)
.clipShape(Capsule())
}
.padding(.horizontal, 14)
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, minHeight: 56)
.background(Color.mealMoodSurface)
}
.frame(maxWidth: .infinity)
.background(Color.mealMoodSurface)
}
private func decideRotation() {
let current = UserDefaults.standard.integer(forKey: Self.counterKey)
let next = current + 1
UserDefaults.standard.set(next, forKey: Self.counterKey)
showRemoveAdsPromo = (next % Self.rotationInterval == 0)
}
}
+34 -6
View File
@@ -15,18 +15,18 @@
], ],
"products" : [ "products" : [
{ {
"displayPrice" : "2.99", "displayPrice" : "39.99",
"familyShareable" : false, "familyShareable" : true,
"internalID" : "B2F4D1A0-3E7C-4F8B-9D2E-1A5C6B0E3F7D", "internalID" : "B2F4D1A0-3E7C-4F8B-9D2E-1A5C6B0E3F7D",
"localizations" : [ "localizations" : [
{ {
"description" : "Lifetime access to all premium features.", "description" : "Lifetime access to all premium features.",
"displayName" : "MealMood Premium (Legacy)", "displayName" : "MealMood Premium Lifetime",
"locale" : "en_US" "locale" : "en_US"
} }
], ],
"productID" : "com.mealmood.premium.monthly", "productID" : "com.mealmood.premium.monthly",
"referenceName" : "MealMood Premium Legacy", "referenceName" : "MealMood Premium Lifetime",
"type" : "NonConsumable" "type" : "NonConsumable"
} }
], ],
@@ -66,7 +66,11 @@
"familyShareable" : true, "familyShareable" : true,
"groupNumber" : 1, "groupNumber" : 1,
"internalID" : "1A32C57F-C4E9-49F8-8F72-28A2B8A1CC4A", "internalID" : "1A32C57F-C4E9-49F8-8F72-28A2B8A1CC4A",
"introductoryOffer" : null, "introductoryOffer" : {
"internalID" : "F47EAB12-9D4F-4BD2-AF52-5C90E6D2C5A1",
"paymentMode" : "free",
"subscriptionPeriod" : "P1W"
},
"localizations" : [ "localizations" : [
{ {
"description" : "Monthly subscription to unlock all premium features.", "description" : "Monthly subscription to unlock all premium features.",
@@ -74,10 +78,34 @@
"locale" : "en_US" "locale" : "en_US"
} }
], ],
"productID" : "com.mealmood.premium.monthly.sub", "productID" : "com.alexandrevazquez.mealmood.premium.monthly.sub",
"recurringSubscriptionPeriod" : "P1M", "recurringSubscriptionPeriod" : "P1M",
"referenceName" : "MealMood Premium Monthly", "referenceName" : "MealMood Premium Monthly",
"subscriptionPricePointID" : "0" "subscriptionPricePointID" : "0"
},
{
"adHocOffers" : [],
"codeOffers" : [],
"displayPrice" : "19.99",
"familyShareable" : true,
"groupNumber" : 2,
"internalID" : "C18B4F7E-3A2D-44C9-B47F-6E1D5E9C2B83",
"introductoryOffer" : {
"internalID" : "A2E5B1F3-7C84-4E59-B0D2-1F8A4D3E7C92",
"paymentMode" : "free",
"subscriptionPeriod" : "P1W"
},
"localizations" : [
{
"description" : "Yearly subscription to unlock all premium features — best value.",
"displayName" : "MealMood Premium Yearly",
"locale" : "en_US"
}
],
"productID" : "com.alexandrevazquez.mealmood.premium.yearly.sub",
"recurringSubscriptionPeriod" : "P1Y",
"referenceName" : "MealMood Premium Yearly",
"subscriptionPricePointID" : "0"
} }
] ]
} }
+2 -2
View File
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.1.4</string> <string>1.1.5</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>50</string> <string>51</string>
<key>GADApplicationIdentifier</key> <key>GADApplicationIdentifier</key>
<string>ca-app-pub-1549720748100858~9985112590</string> <string>ca-app-pub-1549720748100858~9985112590</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
@@ -199,7 +199,31 @@
"premium_purchase_cancelled" = "Der Kauf wurde abgebrochen."; "premium_purchase_cancelled" = "Der Kauf wurde abgebrochen.";
"premium_purchase_failed" = "Der Kauf ist fehlgeschlagen. Bitte versuche es erneut."; "premium_purchase_failed" = "Der Kauf ist fehlgeschlagen. Bitte versuche es erneut.";
"tag_selector_empty" = "Noch keine Tags verfügbar"; "tag_selector_empty" = "Noch keine Tags verfügbar";
"premium_limit_dishes" = "Limit des Gratisplans erreicht: 20 Gerichte"; "premium_limit_dishes" = "Limit des Gratisplans erreicht: 15 Gerichte";
"dish_counter_label" = "Gerichte";
"dish_counter_upgrade_hint" = "Tippe für unbegrenzte Gerichte mit Premium";
"dish_limit_modal_title" = "Du bist auf einer Welle! 🔥";
"dish_limit_modal_message" = "Du hast deinen Gratisplan voll. Hol dir unbegrenzte Gerichte, erweiterte Regeln und mehr mit Premium.";
"dish_limit_modal_cta" = "Premium ansehen";
"dish_limit_modal_dismiss" = "Vielleicht später";
"onboarding_paywall_title" = "MealMood Premium kostenlos testen";
"onboarding_paywall_subtitle" = "Alles für 7 Tage freigeschaltet — unverbindlich.";
"onboarding_paywall_cta_trial" = "Gratis-Test starten";
"onboarding_paywall_cta_skip" = "Gratis weiter";
"remove_ads_promo_title" = "Müde von Werbung?";
"remove_ads_promo_subtitle" = "Hol dir Premium — nie wieder Werbung.";
"remove_ads_promo_cta" = "Werbung entfernen";
"premium_yearly" = "Jährlich";
"premium_year" = "Jahr";
"premium_lifetime" = "Lebenslang";
"premium_one_time" = "einmalig";
"premium_lifetime_badge" = "EINMALIG";
"premium_buy_lifetime" = "Lebenslang kaufen";
"premium_save_badge" = "SPARE %@";
"premium_best_value" = "BESTE WAHL";
"premium_trial_badge" = "7 Tage gratis";
"premium_trial_note" = "7 Tage gratis, dann %@. Jederzeit kündbar.";
"premium_social_proof" = "Geliebt von zufriedenen Familien ★★★★★";
"premium_limit_rules" = "Erweiterte Tag-Regeln sind Premium"; "premium_limit_rules" = "Erweiterte Tag-Regeln sind Premium";
"premium_limit_future_weeks" = "Der Gratisplan erlaubt Planung bis zur nächsten Woche"; "premium_limit_future_weeks" = "Der Gratisplan erlaubt Planung bis zur nächsten Woche";
@@ -199,7 +199,31 @@
"premium_purchase_cancelled" = "Purchase was cancelled."; "premium_purchase_cancelled" = "Purchase was cancelled.";
"premium_purchase_failed" = "Purchase failed. Please try again."; "premium_purchase_failed" = "Purchase failed. Please try again.";
"tag_selector_empty" = "No tags available yet"; "tag_selector_empty" = "No tags available yet";
"premium_limit_dishes" = "Free plan limit reached: 20 dishes"; "premium_limit_dishes" = "Free plan limit reached: 15 dishes";
"dish_counter_label" = "dishes";
"dish_counter_upgrade_hint" = "Tap to get unlimited with Premium";
"dish_limit_modal_title" = "You're cooking up a storm! 🔥";
"dish_limit_modal_message" = "You've filled your free plan. Unlock unlimited dishes, advanced rules, and more with Premium.";
"dish_limit_modal_cta" = "See Premium";
"dish_limit_modal_dismiss" = "Maybe later";
"onboarding_paywall_title" = "Try MealMood Premium free";
"onboarding_paywall_subtitle" = "Unlock everything for 7 days — no commitment.";
"onboarding_paywall_cta_trial" = "Start free trial";
"onboarding_paywall_cta_skip" = "Continue free";
"remove_ads_promo_title" = "Tired of ads?";
"remove_ads_promo_subtitle" = "Go Premium and never see one again.";
"remove_ads_promo_cta" = "Remove ads";
"premium_yearly" = "Yearly";
"premium_year" = "year";
"premium_lifetime" = "Lifetime";
"premium_one_time" = "one-time";
"premium_lifetime_badge" = "PAY ONCE";
"premium_buy_lifetime" = "Buy lifetime";
"premium_save_badge" = "SAVE %@";
"premium_best_value" = "BEST VALUE";
"premium_trial_badge" = "7-day free trial";
"premium_trial_note" = "Free for 7 days, then %@. Cancel anytime.";
"premium_social_proof" = "Trusted by happy families ★★★★★";
"premium_limit_rules" = "Advanced tag rules are Premium"; "premium_limit_rules" = "Advanced tag rules are Premium";
"premium_limit_future_weeks" = "Free plan allows planning up to next week"; "premium_limit_future_weeks" = "Free plan allows planning up to next week";
@@ -199,7 +199,31 @@
"premium_purchase_cancelled" = "La compra se ha cancelado."; "premium_purchase_cancelled" = "La compra se ha cancelado.";
"premium_purchase_failed" = "La compra ha fallado. Inténtalo de nuevo."; "premium_purchase_failed" = "La compra ha fallado. Inténtalo de nuevo.";
"tag_selector_empty" = "Todavía no hay etiquetas"; "tag_selector_empty" = "Todavía no hay etiquetas";
"premium_limit_dishes" = "Límite del plan gratuito: 20 platos"; "premium_limit_dishes" = "Límite del plan gratuito: 15 platos";
"dish_counter_label" = "platos";
"dish_counter_upgrade_hint" = "Toca para tener ilimitados con Premium";
"dish_limit_modal_title" = "¡Estás imparable! 🔥";
"dish_limit_modal_message" = "Has llenado tu plan gratuito. Desbloquea platos ilimitados, reglas avanzadas y más con Premium.";
"dish_limit_modal_cta" = "Ver Premium";
"dish_limit_modal_dismiss" = "Quizá más tarde";
"onboarding_paywall_title" = "Prueba MealMood Premium gratis";
"onboarding_paywall_subtitle" = "Desbloquea todo durante 7 días — sin compromiso.";
"onboarding_paywall_cta_trial" = "Empezar prueba gratis";
"onboarding_paywall_cta_skip" = "Continuar gratis";
"remove_ads_promo_title" = "¿Cansado de anuncios?";
"remove_ads_promo_subtitle" = "Hazte Premium y no volverás a verlos.";
"remove_ads_promo_cta" = "Quitar anuncios";
"premium_yearly" = "Anual";
"premium_year" = "año";
"premium_lifetime" = "De por vida";
"premium_one_time" = "pago único";
"premium_lifetime_badge" = "PAGO ÚNICO";
"premium_buy_lifetime" = "Comprar de por vida";
"premium_save_badge" = "AHORRA %@";
"premium_best_value" = "MEJOR OPCIÓN";
"premium_trial_badge" = "7 días gratis";
"premium_trial_note" = "Gratis 7 días, después %@. Cancela cuando quieras.";
"premium_social_proof" = "Familias felices nos puntúan ★★★★★";
"premium_limit_rules" = "Las reglas avanzadas son Premium"; "premium_limit_rules" = "Las reglas avanzadas son Premium";
"premium_limit_future_weeks" = "El plan gratuito permite planificar hasta la semana siguiente"; "premium_limit_future_weeks" = "El plan gratuito permite planificar hasta la semana siguiente";
@@ -199,7 +199,31 @@
"premium_purchase_cancelled" = "L'achat a été annulé."; "premium_purchase_cancelled" = "L'achat a été annulé.";
"premium_purchase_failed" = "L'achat a échoué. Veuillez réessayer."; "premium_purchase_failed" = "L'achat a échoué. Veuillez réessayer.";
"tag_selector_empty" = "Aucun tag disponible pour le moment"; "tag_selector_empty" = "Aucun tag disponible pour le moment";
"premium_limit_dishes" = "Limite du plan gratuit atteinte : 20 plats"; "premium_limit_dishes" = "Limite du plan gratuit atteinte : 15 plats";
"dish_counter_label" = "plats";
"dish_counter_upgrade_hint" = "Touchez pour des plats illimités avec Premium";
"dish_limit_modal_title" = "Vous êtes en feu ! 🔥";
"dish_limit_modal_message" = "Vous avez rempli votre plan gratuit. Débloquez les plats illimités, règles avancées et plus avec Premium.";
"dish_limit_modal_cta" = "Voir Premium";
"dish_limit_modal_dismiss" = "Plus tard";
"onboarding_paywall_title" = "Essayez MealMood Premium gratuitement";
"onboarding_paywall_subtitle" = "Tout débloqué pendant 7 jours — sans engagement.";
"onboarding_paywall_cta_trial" = "Démarrer l'essai gratuit";
"onboarding_paywall_cta_skip" = "Continuer en gratuit";
"remove_ads_promo_title" = "Fatigué des pubs ?";
"remove_ads_promo_subtitle" = "Passez Premium et plus jamais de publicité.";
"remove_ads_promo_cta" = "Supprimer les pubs";
"premium_yearly" = "Annuel";
"premium_year" = "an";
"premium_lifetime" = "À vie";
"premium_one_time" = "paiement unique";
"premium_lifetime_badge" = "PAIEMENT UNIQUE";
"premium_buy_lifetime" = "Acheter à vie";
"premium_save_badge" = "ÉCONOMISEZ %@";
"premium_best_value" = "MEILLEUR CHOIX";
"premium_trial_badge" = "7 jours gratuits";
"premium_trial_note" = "Gratuit 7 jours, puis %@. Annulez à tout moment.";
"premium_social_proof" = "Adoré par les familles ★★★★★";
"premium_limit_rules" = "Les règles de tags avancées sont réservées au Premium"; "premium_limit_rules" = "Les règles de tags avancées sont réservées au Premium";
"premium_limit_future_weeks" = "Le plan gratuit permet de planifier jusqu'à la semaine prochaine"; "premium_limit_future_weeks" = "Le plan gratuit permet de planifier jusqu'à la semaine prochaine";
@@ -199,7 +199,31 @@
"premium_purchase_cancelled" = "L'acquisto è stato annullato."; "premium_purchase_cancelled" = "L'acquisto è stato annullato.";
"premium_purchase_failed" = "L'acquisto non è riuscito. Riprova."; "premium_purchase_failed" = "L'acquisto non è riuscito. Riprova.";
"tag_selector_empty" = "Nessun tag disponibile al momento"; "tag_selector_empty" = "Nessun tag disponibile al momento";
"premium_limit_dishes" = "Limite del piano gratuito raggiunto: 20 piatti"; "premium_limit_dishes" = "Limite del piano gratuito raggiunto: 15 piatti";
"dish_counter_label" = "piatti";
"dish_counter_upgrade_hint" = "Tocca per piatti illimitati con Premium";
"dish_limit_modal_title" = "Sei in fiamme! 🔥";
"dish_limit_modal_message" = "Hai riempito il piano gratuito. Sblocca piatti illimitati, regole avanzate e altro con Premium.";
"dish_limit_modal_cta" = "Vedi Premium";
"dish_limit_modal_dismiss" = "Forse più tardi";
"onboarding_paywall_title" = "Prova MealMood Premium gratis";
"onboarding_paywall_subtitle" = "Tutto sbloccato per 7 giorni — senza impegno.";
"onboarding_paywall_cta_trial" = "Inizia prova gratuita";
"onboarding_paywall_cta_skip" = "Continua gratis";
"remove_ads_promo_title" = "Stanco delle pubblicità?";
"remove_ads_promo_subtitle" = "Passa a Premium e non vedrai mai più una pubblicità.";
"remove_ads_promo_cta" = "Rimuovi pubblicità";
"premium_yearly" = "Annuale";
"premium_year" = "anno";
"premium_lifetime" = "A vita";
"premium_one_time" = "pagamento unico";
"premium_lifetime_badge" = "UNA TANTUM";
"premium_buy_lifetime" = "Acquista a vita";
"premium_save_badge" = "RISPARMI %@";
"premium_best_value" = "MIGLIOR SCELTA";
"premium_trial_badge" = "7 giorni gratis";
"premium_trial_note" = "Gratis 7 giorni, poi %@. Annulla quando vuoi.";
"premium_social_proof" = "Amato da famiglie felici ★★★★★";
"premium_limit_rules" = "Le regole avanzate dei tag sono Premium"; "premium_limit_rules" = "Le regole avanzate dei tag sono Premium";
"premium_limit_future_weeks" = "Il piano gratuito consente di pianificare fino alla prossima settimana"; "premium_limit_future_weeks" = "Il piano gratuito consente di pianificare fino alla prossima settimana";
@@ -199,7 +199,31 @@
"premium_purchase_cancelled" = "A compra foi cancelada."; "premium_purchase_cancelled" = "A compra foi cancelada.";
"premium_purchase_failed" = "A compra falhou. Tente novamente."; "premium_purchase_failed" = "A compra falhou. Tente novamente.";
"tag_selector_empty" = "Ainda não há tags disponíveis"; "tag_selector_empty" = "Ainda não há tags disponíveis";
"premium_limit_dishes" = "Limite do plano grátis atingido: 20 pratos"; "premium_limit_dishes" = "Limite do plano grátis atingido: 15 pratos";
"dish_counter_label" = "pratos";
"dish_counter_upgrade_hint" = "Toque para pratos ilimitados com Premium";
"dish_limit_modal_title" = "Você está arrasando! 🔥";
"dish_limit_modal_message" = "Você encheu seu plano grátis. Desbloqueie pratos ilimitados, regras avançadas e mais com Premium.";
"dish_limit_modal_cta" = "Ver Premium";
"dish_limit_modal_dismiss" = "Talvez depois";
"onboarding_paywall_title" = "Experimente MealMood Premium grátis";
"onboarding_paywall_subtitle" = "Tudo liberado por 7 dias — sem compromisso.";
"onboarding_paywall_cta_trial" = "Iniciar teste grátis";
"onboarding_paywall_cta_skip" = "Continuar grátis";
"remove_ads_promo_title" = "Cansado de anúncios?";
"remove_ads_promo_subtitle" = "Vire Premium e nunca mais veja um.";
"remove_ads_promo_cta" = "Remover anúncios";
"premium_yearly" = "Anual";
"premium_year" = "ano";
"premium_lifetime" = "Vitalício";
"premium_one_time" = "pagamento único";
"premium_lifetime_badge" = "PAGAMENTO ÚNICO";
"premium_buy_lifetime" = "Comprar vitalício";
"premium_save_badge" = "ECONOMIZE %@";
"premium_best_value" = "MELHOR OPÇÃO";
"premium_trial_badge" = "7 dias grátis";
"premium_trial_note" = "Grátis por 7 dias, depois %@. Cancele quando quiser.";
"premium_social_proof" = "Aprovado por famílias felizes ★★★★★";
"premium_limit_rules" = "Regras avançadas de tags são Premium"; "premium_limit_rules" = "Regras avançadas de tags são Premium";
"premium_limit_future_weeks" = "O plano grátis permite planejar até a próxima semana"; "premium_limit_future_weeks" = "O plano grátis permite planejar até a próxima semana";
+31
View File
@@ -35,6 +35,13 @@ enum AnalyticsEvent {
// Feedback loop // Feedback loop
static let weekRated = "week_rated" static let weekRated = "week_rated"
// Conversion funnel (1.1.5)
static let paywallViewed = "paywall_viewed"
static let paywallDismissed = "paywall_dismissed"
static let dishLimitHit = "dish_limit_hit"
static let weekLimitHit = "week_limit_hit"
static let freeTrialStarted = "free_trial_started"
} }
enum AnalyticsService { enum AnalyticsService {
@@ -118,4 +125,28 @@ enum AnalyticsService {
let value = rating == 1 ? "liked" : rating == -1 ? "disliked" : "cleared" let value = rating == 1 ? "liked" : rating == -1 ? "disliked" : "cleared"
logEvent(AnalyticsEvent.weekRated, parameters: ["rating": value]) logEvent(AnalyticsEvent.weekRated, parameters: ["rating": value])
} }
static func logPaywallViewed(source: String) {
logEvent(AnalyticsEvent.paywallViewed, parameters: ["source": source])
}
static func logPaywallDismissed(source: String, secondsOnScreen: Int, didConvert: Bool) {
logEvent(AnalyticsEvent.paywallDismissed, parameters: [
"source": source,
"seconds_on_screen": secondsOnScreen,
"did_convert": didConvert
])
}
static func logDishLimitHit() {
logEvent(AnalyticsEvent.dishLimitHit)
}
static func logWeekLimitHit() {
logEvent(AnalyticsEvent.weekLimitHit)
}
static func logFreeTrialStarted(plan: String) {
logEvent(AnalyticsEvent.freeTrialStarted, parameters: ["plan": plan])
}
} }
+1 -1
View File
@@ -1,7 +1,7 @@
import Foundation import Foundation
enum PremiumAccess { enum PremiumAccess {
static let freeDishLimit = 20 static let freeDishLimit = 15
static let freeFutureWeeks = 1 static let freeFutureWeeks = 1
static func hasReachedFreeDishLimit(dishCount: Int, isPremium: Bool) -> Bool { static func hasReachedFreeDishLimit(dishCount: Int, isPremium: Bool) -> Bool {
+1 -1
View File
@@ -7,7 +7,7 @@ final class ReviewPromptService {
private let reviewCompletedKey = "review_funnel_completed" private let reviewCompletedKey = "review_funnel_completed"
private let lastPromptedCompletedWeeksKey = "review_funnel_last_prompted_completed_weeks" private let lastPromptedCompletedWeeksKey = "review_funnel_last_prompted_completed_weeks"
private let minimumCompletedWeeksToStart = 2 private let minimumCompletedWeeksToStart = 1
private let promptIntervalWeeks = 2 private let promptIntervalWeeks = 2
private init() {} private init() {}
+35 -3
View File
@@ -28,12 +28,14 @@ final class StoreManager: ObservableObject {
@Published var debugLoadedProducts: [String] = [] @Published var debugLoadedProducts: [String] = []
static let monthlyProductId = "com.alexandrevazquez.mealmood.premium.monthly.sub" static let monthlyProductId = "com.alexandrevazquez.mealmood.premium.monthly.sub"
static let yearlyProductId = "com.alexandrevazquez.mealmood.premium.yearly.sub"
static let legacyOneTimeProductId = "com.mealmood.premium.monthly" static let legacyOneTimeProductId = "com.mealmood.premium.monthly"
private var productIds: [String] { private var productIds: [String] {
var ids = [Self.monthlyProductId, Self.legacyOneTimeProductId] var ids = [Self.monthlyProductId, Self.yearlyProductId, Self.legacyOneTimeProductId]
if let bundleId = Bundle.main.bundleIdentifier { if let bundleId = Bundle.main.bundleIdentifier {
ids.append("\(bundleId).premium.monthly.sub") ids.append("\(bundleId).premium.monthly.sub")
ids.append("\(bundleId).premium.yearly.sub")
ids.append("\(bundleId).premium.monthly") ids.append("\(bundleId).premium.monthly")
} }
@@ -120,14 +122,44 @@ final class StoreManager: ObservableObject {
if let match = products.first(where: { $0.id == Self.monthlyProductId && $0.type == .autoRenewable }) { if let match = products.first(where: { $0.id == Self.monthlyProductId && $0.type == .autoRenewable }) {
return match return match
} }
return products.first(where: { $0.type == .autoRenewable }) return products.first(where: { $0.type == .autoRenewable && $0.subscription?.subscriptionPeriod.unit == .month })
}
var yearlyProduct: Product? {
if let match = products.first(where: { $0.id == Self.yearlyProductId && $0.type == .autoRenewable }) {
return match
}
return products.first(where: { $0.type == .autoRenewable && $0.subscription?.subscriptionPeriod.unit == .year })
}
var lifetimeProduct: Product? {
products.first(where: { $0.type == .nonConsumable })
}
var yearlySavingsPercent: Int? {
guard let monthly = monthlyProduct, let yearly = yearlyProduct else { return nil }
let monthlyAnnualised = monthly.price * 12
guard monthlyAnnualised > 0 else { return nil }
let savings = (monthlyAnnualised - yearly.price) / monthlyAnnualised
let percent = Int((NSDecimalNumber(decimal: savings).doubleValue * 100).rounded())
return percent > 0 ? percent : nil
}
var hasTrialAvailable: Bool {
guard let yearly = yearlyProduct, let intro = yearly.subscription?.introductoryOffer else { return false }
return intro.paymentMode == .freeTrial
} }
var debugProductIds: [String] { productIds } var debugProductIds: [String] { productIds }
static func hasActiveSubscription() async -> Bool { static func hasActiveSubscription() async -> Bool {
let bundleId = Bundle.main.bundleIdentifier ?? "" let bundleId = Bundle.main.bundleIdentifier ?? ""
let subscriptionIds: Set<String> = [monthlyProductId, "\(bundleId).premium.monthly.sub"] let subscriptionIds: Set<String> = [
monthlyProductId,
yearlyProductId,
"\(bundleId).premium.monthly.sub",
"\(bundleId).premium.yearly.sub"
]
// Legacy one-time purchases grant lifetime access no expiration check needed. // Legacy one-time purchases grant lifetime access no expiration check needed.
let legacyIds: Set<String> = [legacyOneTimeProductId, "\(bundleId).premium.monthly"] let legacyIds: Set<String> = [legacyOneTimeProductId, "\(bundleId).premium.monthly"]
@@ -27,7 +27,7 @@ final class OnboardingViewModel: ObservableObject {
@Published var addedDishes: [(name: String, tagIds: [UUID])] = [] @Published var addedDishes: [(name: String, tagIds: [UUID])] = []
let maxOnboardingDishes = 10 let maxOnboardingDishes = 10
let totalSteps = 5 let totalSteps = 6
var canContinue: Bool { var canContinue: Bool {
switch currentStep { switch currentStep {
@@ -36,6 +36,7 @@ final class OnboardingViewModel: ObservableObject {
case 2: return true // Weekends (toggle) case 2: return true // Weekends (toggle)
case 3: return true // Calendar (optional) case 3: return true // Calendar (optional)
case 4: return true // Dishes are optional case 4: return true // Dishes are optional
case 5: return true // Paywall (always skippable)
default: return false default: return false
} }
} }
+58 -14
View File
@@ -9,7 +9,7 @@ struct DishFormView: View {
@Query private var allSettings: [AppSettings] @Query private var allSettings: [AppSettings]
@StateObject private var viewModel = DishViewModel() @StateObject private var viewModel = DishViewModel()
@State private var showPremium = false @State private var showPremium = false
@State private var showDishLimitUpsell = false @State private var showDishLimitModal = false
@State private var showSavedFeedback = false @State private var showSavedFeedback = false
var dish: Dish? var dish: Dish?
@@ -154,14 +154,6 @@ struct DishFormView: View {
.background(Color.mealMoodSurface) .background(Color.mealMoodSurface)
.cornerRadius(14) .cornerRadius(14)
if showDishLimitUpsell && reachedFreeDishLimit {
PremiumUpsellBanner(
messageKey: "premium_limit_dishes",
actionTitleKey: "premium_subscribe"
) {
showPremium = true
}
}
// Delete button (edit mode only) // Delete button (edit mode only)
if viewModel.isEditing { if viewModel.isEditing {
@@ -195,10 +187,8 @@ struct DishFormView: View {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button { Button {
guard !reachedFreeDishLimit else { guard !reachedFreeDishLimit else {
withAnimation(.easeInOut(duration: 0.2)) { AnalyticsService.logDishLimitHit()
showDishLimitUpsell = true showDishLimitModal = true
}
showPremium = true
HapticManager.shared.notification(type: .warning) HapticManager.shared.notification(type: .warning)
return return
} }
@@ -226,9 +216,22 @@ struct DishFormView: View {
} }
.sheet(isPresented: $showPremium) { .sheet(isPresented: $showPremium) {
if let settings = allSettings.first { if let settings = allSettings.first {
NavigationStack { PremiumView(settings: settings) } NavigationStack { PremiumView(settings: settings, source: "dish_limit") }
} }
} }
.sheet(isPresented: $showDishLimitModal) {
DishLimitModal(
onSeePremium: {
showDishLimitModal = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
showPremium = true
}
},
onDismiss: { showDishLimitModal = false }
)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
.alert("dish_delete_title", isPresented: $viewModel.showDeleteAlert) { .alert("dish_delete_title", isPresented: $viewModel.showDeleteAlert) {
Button("dish_cancel", role: .cancel) {} Button("dish_cancel", role: .cancel) {}
Button("dish_delete", role: .destructive) { Button("dish_delete", role: .destructive) {
@@ -255,6 +258,47 @@ struct DishFormView: View {
} }
// Simple flow layout for tags // Simple flow layout for tags
private struct DishLimitModal: View {
let onSeePremium: () -> Void
let onDismiss: () -> Void
var body: some View {
VStack(spacing: 20) {
Image(systemName: "sparkles")
.font(.system(size: 44))
.foregroundColor(.mealMoodCoral)
.padding(.top, 24)
VStack(spacing: 8) {
Text("dish_limit_modal_title")
.font(.mealMoodH2)
.foregroundColor(.mealMoodTextPrimary)
.multilineTextAlignment(.center)
Text("dish_limit_modal_message")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 24)
Spacer()
VStack(spacing: 10) {
PrimaryButton(title: String(localized: "dish_limit_modal_cta"), action: onSeePremium)
Button(action: onDismiss) {
Text("dish_limit_modal_dismiss")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
}
}
.padding(.horizontal, 24)
.padding(.bottom, 24)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.mealMoodBackground.ignoresSafeArea())
}
}
struct FlowLayout: Layout { struct FlowLayout: Layout {
var spacing: CGFloat = 8 var spacing: CGFloat = 8
+52
View File
@@ -10,6 +10,7 @@ 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
private var language: AppLanguage { private var language: AppLanguage {
(allSettings.first?.languageEnum ?? .system).resolved() (allSettings.first?.languageEnum ?? .system).resolved()
@@ -31,6 +32,10 @@ struct DishListView: View {
.padding(.horizontal, 60) .padding(.horizontal, 60)
} }
} else { } else {
VStack(spacing: 0) {
if let settings = allSettings.first, !settings.isPremium {
dishCounterBanner
}
List { List {
ForEach(dishes, id: \.id) { dish in ForEach(dishes, id: \.id) { dish in
let dishTags = tags.filter { dish.tagIds.contains($0.id) } let dishTags = tags.filter { dish.tagIds.contains($0.id) }
@@ -67,6 +72,7 @@ struct DishListView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
} }
} }
}
.navigationTitle("home_my_dishes") .navigationTitle("home_my_dishes")
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
@@ -78,6 +84,11 @@ struct DishListView: View {
} }
} }
} }
.sheet(isPresented: $showPremium) {
if let settings = allSettings.first {
NavigationStack { PremiumView(settings: settings, source: "dish_counter") }
}
}
.sheet(isPresented: $showDishForm) { .sheet(isPresented: $showDishForm) {
DishFormView() DishFormView()
} }
@@ -91,6 +102,47 @@ struct DishListView: View {
} }
} }
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) { 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 }
+10 -2
View File
@@ -13,6 +13,7 @@ struct HomeView: View {
@StateObject private var viewModel = HomeViewModel() @StateObject private var viewModel = HomeViewModel()
@State private var editingDish: Dish? @State private var editingDish: Dish?
@State private var showPremiumFromExport: Bool = false @State private var showPremiumFromExport: Bool = false
@State private var paywallSource: String = "unknown"
@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
@@ -111,6 +112,8 @@ struct HomeView: View {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
showWeekLimitUpsell = true showWeekLimitUpsell = true
} }
AnalyticsService.logWeekLimitHit()
paywallSource = "week_limit"
showPremiumFromExport = true showPremiumFromExport = true
HapticManager.shared.notification(type: .warning) HapticManager.shared.notification(type: .warning)
} }
@@ -218,6 +221,7 @@ struct HomeView: View {
messageKey: "premium_limit_future_weeks", messageKey: "premium_limit_future_weeks",
actionTitleKey: "premium_subscribe" actionTitleKey: "premium_subscribe"
) { ) {
paywallSource = "week_limit_banner"
showPremiumFromExport = true showPremiumFromExport = true
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
@@ -267,7 +271,7 @@ struct HomeView: View {
.frame(maxHeight: .infinity, alignment: .top) .frame(maxHeight: .infinity, alignment: .top)
.safeAreaInset(edge: .bottom, spacing: 0) { .safeAreaInset(edge: .bottom, spacing: 0) {
if !settings.isPremium && !isRunningOnMac { if !settings.isPremium && !isRunningOnMac {
AdBannerView() BottomPromoBanner(settings: settings)
} }
} }
.alert("reset_title", isPresented: $viewModel.showResetAlert) { .alert("reset_title", isPresented: $viewModel.showResetAlert) {
@@ -312,6 +316,7 @@ struct HomeView: View {
.alert("onboarding_premium_prompt_title", isPresented: $showPostOnboardingPremiumPrompt) { .alert("onboarding_premium_prompt_title", isPresented: $showPostOnboardingPremiumPrompt) {
Button("onboarding_premium_prompt_cta") { Button("onboarding_premium_prompt_cta") {
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey) UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey)
paywallSource = "post_onboarding"
showPremiumFromExport = true showPremiumFromExport = true
} }
Button("reset_cancel", role: .cancel) { Button("reset_cancel", role: .cancel) {
@@ -459,7 +464,7 @@ struct HomeView: View {
} }
.sheet(isPresented: $showPremiumFromExport) { .sheet(isPresented: $showPremiumFromExport) {
NavigationStack { NavigationStack {
PremiumView(settings: settings) PremiumView(settings: settings, source: paywallSource)
} }
} }
.sheet(isPresented: $showExportStylePicker) { .sheet(isPresented: $showExportStylePicker) {
@@ -727,6 +732,8 @@ struct HomeView: View {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
showWeekLimitUpsell = true showWeekLimitUpsell = true
} }
AnalyticsService.logWeekLimitHit()
paywallSource = "week_picker"
showPremiumFromExport = true showPremiumFromExport = true
HapticManager.shared.notification(type: .warning) HapticManager.shared.notification(type: .warning)
} }
@@ -819,6 +826,7 @@ struct HomeView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} else { } else {
Button { Button {
paywallSource = "share_week"
showPremiumFromExport = true showPremiumFromExport = true
} label: { } label: {
Label("share_week_button", systemImage: "star.fill") Label("share_week_button", systemImage: "star.fill")
@@ -63,13 +63,19 @@ struct OnboardingView: View {
viewModel: viewModel, viewModel: viewModel,
tags: tags, tags: tags,
onFinish: { onFinish: {
if viewModel.completeOnboarding(context: context) { _ = viewModel.completeOnboarding(context: context)
onComplete() viewModel.nextStep()
}
} }
) )
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.tag(4) .tag(4)
PaywallStepView(
onSkip: { onComplete() },
onConverted: { onComplete() }
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.tag(5)
} }
.tabViewStyle(.page(indexDisplayMode: .never)) .tabViewStyle(.page(indexDisplayMode: .never))
.animation(.easeInOut(duration: 0.3), value: viewModel.currentStep) .animation(.easeInOut(duration: 0.3), value: viewModel.currentStep)
@@ -0,0 +1,126 @@
import SwiftUI
import SwiftData
struct PaywallStepView: View {
@Environment(\.modelContext) private var context
@Query private var allSettings: [AppSettings]
@StateObject private var storeManager = StoreManager()
var onSkip: () -> Void
var onConverted: () -> Void
@State private var purchaseStatusMessageKey: String?
var body: some View {
ZStack {
Color.mealMoodBackground.ignoresSafeArea()
VStack(spacing: 24) {
AppIconPlaceholder(size: 84)
.padding(.top, 20)
VStack(spacing: 8) {
Text("onboarding_paywall_title")
.font(.mealMoodH1)
.foregroundColor(.mealMoodTextPrimary)
.multilineTextAlignment(.center)
Text("onboarding_paywall_subtitle")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 24)
VStack(alignment: .leading, spacing: 10) {
benefitRow(text: String(localized: "premium_unlimited_dishes"))
benefitRow(text: String(localized: "premium_advanced_rules"))
benefitRow(text: String(localized: "premium_future_weeks"))
benefitRow(text: String(localized: "premium_no_ads"))
}
.padding(16)
.background(Color.mealMoodSurface)
.clipShape(RoundedRectangle(cornerRadius: 14))
.padding(.horizontal, 24)
Text("premium_social_proof")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
Spacer(minLength: 0)
VStack(spacing: 12) {
PrimaryButton(
title: trialButtonTitle,
action: { Task { await startTrial() } },
isEnabled: !storeManager.isLoading
)
Button(action: skipPaywall) {
Text("onboarding_paywall_cta_skip")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
}
}
.padding(.horizontal, 24)
.padding(.bottom, 24)
}
}
.task { await storeManager.loadProducts() }
.onAppear { AnalyticsService.logPaywallViewed(source: "onboarding") }
.alert("premium_title", isPresented: Binding(
get: { purchaseStatusMessageKey != nil },
set: { if !$0 { purchaseStatusMessageKey = nil } }
)) {
Button("dish_delete_blocked_ok", role: .cancel) {}
} message: {
Text(LocalizedStringKey(purchaseStatusMessageKey ?? ""))
}
}
private var trialButtonTitle: String {
if let yearly = storeManager.yearlyProduct {
let format = String(localized: "premium_trial_note")
let priceLabel = yearly.displayPrice + " / " + String(localized: "premium_year")
return String(localized: "onboarding_paywall_cta_trial") + "" + String(format: format, priceLabel)
}
return String(localized: "onboarding_paywall_cta_trial")
}
@ViewBuilder
private func benefitRow(text: String) -> some View {
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.mealMoodSuccess)
Text(text)
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextPrimary)
Spacer()
}
}
private func startTrial() async {
let candidate = storeManager.yearlyProduct ?? storeManager.monthlyProduct
guard let product = candidate else {
purchaseStatusMessageKey = "premium_products_not_found"
return
}
AnalyticsService.logPremiumUpgradeTapped(source: "onboarding")
let result = await storeManager.purchase(product)
switch result {
case .success:
if let settings = allSettings.first {
settings.isPremium = true
try? context.save()
}
AnalyticsService.logFreeTrialStarted(plan: product.id)
onConverted()
case .pending: purchaseStatusMessageKey = "premium_purchase_pending"
case .cancelled: purchaseStatusMessageKey = "premium_purchase_cancelled"
case .failed: purchaseStatusMessageKey = "premium_purchase_failed"
}
}
private func skipPaywall() {
AnalyticsService.logPaywallDismissed(source: "onboarding", secondsOnScreen: 0, didConvert: false)
onSkip()
}
}
+216 -32
View File
@@ -5,16 +5,24 @@ struct PremiumView: View {
@Environment(\.modelContext) private var context @Environment(\.modelContext) private var context
@StateObject private var storeManager = StoreManager() @StateObject private var storeManager = StoreManager()
@State private var purchaseStatusMessageKey: String? @State private var purchaseStatusMessageKey: String?
@State private var appearedAt: Date = Date()
@State private var didConvert: Bool = false
@State private var selectedPlan: PlanChoice = .yearly
let settings: AppSettings let settings: AppSettings
var source: String = "unknown"
enum PlanChoice {
case monthly, yearly, lifetime
}
var body: some View { var body: some View {
ZStack { ZStack {
Color.mealMoodBackground.ignoresSafeArea() Color.mealMoodBackground.ignoresSafeArea()
ScrollView { ScrollView {
VStack(spacing: 24) { VStack(spacing: 22) {
VStack(spacing: 16) { VStack(spacing: 12) {
AppIconPlaceholder(size: 84) AppIconPlaceholder(size: 84)
Text("premium_title") Text("premium_title")
@@ -25,10 +33,28 @@ struct PremiumView: View {
.font(.mealMoodBody) .font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary) .foregroundColor(.mealMoodTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Text("premium_social_proof")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
.padding(.top, 2)
} }
.padding(.top, 28) .padding(.top, 24)
.padding(.horizontal, 24) .padding(.horizontal, 24)
if settings.isPremium {
Label("premium_active", systemImage: "checkmark.seal.fill")
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodSuccess)
.padding(.horizontal, 24)
} else {
planSelector
.padding(.horizontal, 24)
ctaButton
.padding(.horizontal, 24)
}
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
BenefitItem(text: String(localized: "premium_no_ads")) BenefitItem(text: String(localized: "premium_no_ads"))
BenefitItem(text: String(localized: "premium_unlimited_dishes")) BenefitItem(text: String(localized: "premium_unlimited_dishes"))
@@ -37,38 +63,13 @@ struct PremiumView: View {
BenefitItem(text: String(localized: "premium_share_week")) BenefitItem(text: String(localized: "premium_share_week"))
BenefitItem(text: String(localized: "premium_family_sharing")) BenefitItem(text: String(localized: "premium_family_sharing"))
} }
.padding(20) .padding(16)
.background(Color.mealMoodSurface) .background(Color.mealMoodSurface)
.cornerRadius(16) .cornerRadius(16)
.padding(.horizontal, 24) .padding(.horizontal, 24)
if settings.isPremium { if !settings.isPremium && storeManager.monthlyProduct == nil && storeManager.yearlyProduct == nil && storeManager.lifetimeProduct == nil {
Label("premium_active", systemImage: "checkmark.seal.fill") productLoadFallback
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodSuccess)
.padding(.horizontal, 24)
} else if let monthly = storeManager.monthlyProduct {
PlanCard(
title: String(localized: "premium_monthly"),
price: monthly.displayPrice + " / " + String(localized: "premium_month"),
caption: String(localized: "premium_price_note"),
isLoading: storeManager.isLoading
) {
Task { await purchase(monthly) }
}
.padding(.horizontal, 24)
} else {
let fallbackText = fallbackContent(for: storeManager.productLoadState)
PlanCard(
title: String(localized: "premium_monthly"),
price: fallbackText.price,
caption: fallbackText.hint,
buttonTitle: String(localized: "premium_retry_products"),
isLoading: storeManager.isLoadingProducts
) {
Task { await storeManager.loadProducts() }
}
.padding(.horizontal, 24) .padding(.horizontal, 24)
#if DEBUG #if DEBUG
@@ -128,6 +129,14 @@ struct PremiumView: View {
.task { .task {
await storeManager.loadProducts() await storeManager.loadProducts()
} }
.onAppear {
appearedAt = Date()
AnalyticsService.logPaywallViewed(source: source)
}
.onDisappear {
let seconds = Int(Date().timeIntervalSince(appearedAt))
AnalyticsService.logPaywallDismissed(source: source, secondsOnScreen: seconds, didConvert: didConvert)
}
.onChange(of: storeManager.isPremium) { _, isPremium in .onChange(of: storeManager.isPremium) { _, isPremium in
if settings.isPremium != isPremium { if settings.isPremium != isPremium {
settings.isPremium = isPremium settings.isPremium = isPremium
@@ -146,12 +155,187 @@ struct PremiumView: View {
} }
} }
@ViewBuilder
private var planSelector: some View {
VStack(spacing: 10) {
HStack(spacing: 10) {
planChip(.yearly)
planChip(.monthly)
}
if storeManager.lifetimeProduct != nil {
planChip(.lifetime, fullWidth: true)
}
}
}
@ViewBuilder
private func planChip(_ plan: PlanChoice, fullWidth: Bool = false) -> some View {
let isSelected = selectedPlan == plan
let product: Product? = {
switch plan {
case .monthly: return storeManager.monthlyProduct
case .yearly: return storeManager.yearlyProduct
case .lifetime: return storeManager.lifetimeProduct
}
}()
let titleKey: LocalizedStringKey = {
switch plan {
case .monthly: return "premium_monthly"
case .yearly: return "premium_yearly"
case .lifetime: return "premium_lifetime"
}
}()
let priceSuffix: String = {
switch plan {
case .monthly: return " / " + String(localized: "premium_month")
case .yearly: return " / " + String(localized: "premium_year")
case .lifetime: return " · " + String(localized: "premium_one_time")
}
}()
Button {
selectedPlan = plan
HapticManager.shared.impact(style: .light)
} label: {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(titleKey)
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
Spacer()
if plan == .yearly, let savings = storeManager.yearlySavingsPercent {
Text(String(format: String(localized: "premium_save_badge"), "\(savings)%"))
.font(.system(size: 10, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.mealMoodCoral)
.clipShape(Capsule())
}
if plan == .lifetime {
Text("premium_lifetime_badge")
.font(.system(size: 10, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.mealMoodTextPrimary)
.clipShape(Capsule())
}
}
if let product = product {
Text(product.displayPrice + priceSuffix)
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
} else {
Text("premium_loading_products")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
if plan == .yearly {
Text("premium_best_value")
.font(.system(size: 9, weight: .bold))
.foregroundColor(.mealMoodCoral)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(isSelected ? Color.mealMoodCoral.opacity(0.08) : Color.mealMoodSurface)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(isSelected ? Color.mealMoodCoral : Color(hex: "#E0E0E0"), lineWidth: isSelected ? 2 : 1)
)
}
.buttonStyle(.plain)
}
@ViewBuilder
private var ctaButton: some View {
let selectedProduct = currentSelectedProduct
VStack(spacing: 8) {
if selectedHasTrial {
Text("premium_trial_badge")
.font(.mealMoodCaption.weight(.semibold))
.foregroundColor(.mealMoodCoral)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Color.mealMoodCoral.opacity(0.12))
.clipShape(Capsule())
}
PrimaryButton(
title: ctaTitle,
action: { if let p = selectedProduct { Task { await purchase(p) } } },
isEnabled: !storeManager.isLoading && selectedProduct != nil
)
if let product = selectedProduct, selectedHasTrial {
Text(String(format: String(localized: "premium_trial_note"),
product.displayPrice + " / " + String(localized: selectedPlan == .yearly ? "premium_year" : "premium_month")))
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
.multilineTextAlignment(.center)
}
}
}
private var currentSelectedProduct: Product? {
switch selectedPlan {
case .monthly: return storeManager.monthlyProduct
case .yearly: return storeManager.yearlyProduct
case .lifetime: return storeManager.lifetimeProduct
}
}
private var selectedHasTrial: Bool {
guard selectedPlan != .lifetime else { return false }
return storeManager.hasTrialAvailable
}
private var ctaTitle: String {
if storeManager.isLoading {
return String(localized: "premium_processing")
}
if selectedPlan == .lifetime {
return String(localized: "premium_buy_lifetime")
}
if selectedHasTrial {
return String(localized: "onboarding_paywall_cta_trial")
}
return String(localized: "premium_subscribe")
}
@ViewBuilder
private var productLoadFallback: some View {
let text = fallbackContent(for: storeManager.productLoadState)
VStack(spacing: 10) {
Text(text.price)
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
.multilineTextAlignment(.center)
Text(text.hint)
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
.multilineTextAlignment(.center)
PrimaryButton(
title: String(localized: "premium_retry_products"),
action: { Task { await storeManager.loadProducts() } },
isEnabled: !storeManager.isLoadingProducts
)
}
.padding(16)
.background(Color.mealMoodSurface)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
private func purchase(_ product: Product) async { private func purchase(_ product: Product) async {
AnalyticsService.logPremiumUpgradeTapped(source: "premium_view") AnalyticsService.logPremiumUpgradeTapped(source: source)
let result = await storeManager.purchase(product) let result = await storeManager.purchase(product)
switch result { switch result {
case .success: case .success:
settings.isPremium = true settings.isPremium = true
didConvert = true
if storeManager.hasTrialAvailable {
AnalyticsService.logFreeTrialStarted(plan: product.id)
}
try? context.save() try? context.save()
case .pending: case .pending:
purchaseStatusMessageKey = "premium_purchase_pending" purchaseStatusMessageKey = "premium_purchase_pending"
+1 -1
View File
@@ -181,7 +181,7 @@ struct SettingsView: View {
.foregroundColor(.mealMoodTextSecondary) .foregroundColor(.mealMoodTextSecondary)
} }
NavigationLink(destination: PremiumView(settings: settings)) { NavigationLink(destination: PremiumView(settings: settings, source: "settings")) {
Text("settings_remove_ads") Text("settings_remove_ads")
} }
} header: { } header: {
+1 -1
View File
@@ -203,7 +203,7 @@ struct TagRulesEditView: View {
} }
.sheet(isPresented: $showPremium) { .sheet(isPresented: $showPremium) {
if let settings = allSettings.first { if let settings = allSettings.first {
NavigationStack { PremiumView(settings: settings) } NavigationStack { PremiumView(settings: settings, source: "tag_rules") }
} }
} }
} }
+2 -2
View File
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>XPC!</string> <string>XPC!</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.1.4</string> <string>1.1.5</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>50</string> <string>51</string>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>
+217
View File
@@ -0,0 +1,217 @@
# App Store Connect — Setup para 1.1.5
Esta guía cubre los 3 cambios manuales que hay que hacer en App Store Connect antes de subir la build 51 a TestFlight / App Store. Todo está alineado con `MealMood.storekit` local, así que puedes probar el flujo en simulador antes de tocar producción.
Resumen:
1. **Crear yearly subscription** ($19.99 / 7-day trial)
2. **Añadir 7-day trial al monthly existente**
3. **Actualizar el lifetime existente** (precio $39.99, renaming opcional)
---
## 1. Crear yearly subscription
**Path:** App Store Connect → MealMood → Subscriptions → Subscription Group "MealMood Premium" → **+** Create Subscription
### Datos principales
| Campo | Valor |
|---|---|
| Reference Name | `MealMood Premium Yearly` |
| Product ID | `com.alexandrevazquez.mealmood.premium.yearly.sub` |
| Subscription Group | MealMood Premium *(mismo grupo que el monthly)* |
| Subscription Duration | 1 year |
| Price (USD) | **$19.99** |
| Family Sharing | ✅ Enabled |
| Available Countries | Mismas que el monthly (All Countries/Regions recomendado) |
| Cleared for Sale | ✅ |
> ️ Al guardar, App Store Connect te asignará automáticamente el tier de precio equivalente en cada divisa.
### Localizations (6 idiomas)
**Subscription Display Name** + **Description** por idioma:
#### English (U.S.) — Primary
- **Display Name:** `MealMood Premium Yearly`
- **Description:** `Unlimited dishes, advanced rotation rules, ad-free and stats — billed yearly. Best value, save vs monthly.`
#### Spanish (Spain)
- **Display Name:** `MealMood Premium Anual`
- **Description:** `Platos ilimitados, reglas avanzadas de rotación, sin anuncios y estadísticas — facturación anual. La mejor oferta, ahorra frente al plan mensual.`
#### French (France)
- **Display Name:** `MealMood Premium Annuel`
- **Description:** `Plats illimités, règles avancées de rotation, sans publicité et statistiques — facturation annuelle. Meilleur prix, économisez par rapport au mensuel.`
#### German
- **Display Name:** `MealMood Premium Jährlich`
- **Description:** `Unbegrenzte Gerichte, erweiterte Rotationsregeln, werbefrei und Statistiken — jährliche Abrechnung. Bestes Angebot, sparen Sie gegenüber dem Monatsplan.`
#### Italian
- **Display Name:** `MealMood Premium Annuale`
- **Description:** `Piatti illimitati, regole avanzate di rotazione, senza pubblicità e statistiche — fatturazione annuale. Miglior prezzo, risparmia rispetto al piano mensile.`
#### Portuguese (Brazil)
- **Display Name:** `MealMood Premium Anual`
- **Description:** `Pratos ilimitados, regras avançadas de rotação, sem anúncios e estatísticas — cobrança anual. Melhor oferta, economize em relação ao plano mensal.`
### Subscription Introductory Offer (free trial)
**Path:** En el detalle del yearly → **Subscription Prices****Introductory Offers****+** Create Introductory Offer
| Campo | Valor |
|---|---|
| Countries | All Countries (where the subscription is available) |
| Offer Type | **Free** |
| Duration | **1 Week** |
| Available to | New Subscribers only |
| Start Date | Today |
| End Date | (none — open-ended) |
> ⚠️ Esto es lo que activa el "7-day free trial" en la app. Sin esto, el `introductoryOffer` será null en el StoreKit response.
### Review Information (lo verá Apple Review)
- **Screenshot:** sube una captura del paywall mostrando el plan anual seleccionado con el badge "BEST VALUE" y el botón "Start free trial"
- **Review Notes:**
```
Yearly subscription unlocking Premium features: unlimited dishes,
advanced tag rotation rules, ad removal, statistics dashboard,
unlimited future weeks planning, and meal plan export.
Includes a 7-day free trial for new subscribers.
To test: launch app → Settings → Premium, or trigger any paywall
from dish limit / share button.
```
---
## 2. Añadir 7-day free trial al monthly existente
**Path:** App Store Connect → MealMood → Subscriptions → `com.alexandrevazquez.mealmood.premium.monthly.sub` → **Subscription Prices** → **Introductory Offers** → **+** Create Introductory Offer
| Campo | Valor |
|---|---|
| Countries | All Countries (where the subscription is available) |
| Offer Type | **Free** |
| Duration | **1 Week** |
| Available to | New Subscribers only |
| Start Date | Today |
| End Date | (none) |
> ️ No hace falta tocar nada más del monthly. El precio se queda en $2.99, sólo se le añade el trial.
---
## 3. Actualizar el lifetime existente
**Path:** App Store Connect → MealMood → In-App Purchases → `com.mealmood.premium.monthly` (es Non-Consumable, no toques el ID — sí, el nombre es engañoso pero es el legacy lifetime)
### Cambios
| Campo | Valor actual | Valor nuevo |
|---|---|---|
| Reference Name | MealMood Premium Legacy | `MealMood Premium Lifetime` |
| Price (USD) | $2.99 | **$39.99** |
| Family Sharing | Disabled | ✅ **Enabled** |
| Cleared for Sale | ✅ | ✅ (mantener) |
> ⚠️ El **Product ID NO se puede cambiar** una vez creado. Por eso aunque diga `monthly` en el nombre, sigue siendo nuestro lifetime. El código ya lo trata como tal (`StoreManager.legacyOneTimeProductId`).
### Localizations (6 idiomas)
Actualiza los display names y descriptions:
#### English (U.S.) — Primary
- **Display Name:** `MealMood Premium Lifetime`
- **Description:** `Unlock everything forever with a single one-time payment. No subscription, no renewals — yours to keep.`
#### Spanish (Spain)
- **Display Name:** `MealMood Premium De Por Vida`
- **Description:** `Desbloquea todo para siempre con un único pago. Sin suscripción, sin renovaciones — tuyo para siempre.`
#### French (France)
- **Display Name:** `MealMood Premium À Vie`
- **Description:** `Débloquez tout pour toujours avec un seul paiement unique. Sans abonnement, sans renouvellement — c'est à vous pour toujours.`
#### German
- **Display Name:** `MealMood Premium Lebenslang`
- **Description:** `Schalte alles dauerhaft mit einer einmaligen Zahlung frei. Kein Abo, keine Verlängerung — gehört dir für immer.`
#### Italian
- **Display Name:** `MealMood Premium A Vita`
- **Description:** `Sblocca tutto per sempre con un unico pagamento. Niente abbonamento, niente rinnovi — è tuo per sempre.`
#### Portuguese (Brazil)
- **Display Name:** `MealMood Premium Vitalício`
- **Description:** `Desbloqueie tudo para sempre com um único pagamento. Sem assinatura, sem renovações — é seu para sempre.`
### Review Information (si Apple lo pide)
- **Screenshot:** captura del paywall con el lifetime visible debajo de los planes mensual/anual con el badge "PAY ONCE"
- **Review Notes:**
```
One-time purchase unlocking all Premium features permanently:
unlimited dishes, advanced tag rules, ad removal, statistics,
unlimited future weeks, meal plan export. Family Sharing enabled.
Acts as an alternative to the monthly/yearly subscriptions for
users who prefer a single payment.
```
---
## 4. Verificación post-setup
Antes de subir la build:
### Comprobaciones rápidas
- [ ] El yearly aparece en el subscription group **MealMood Premium**
- [ ] Yearly tiene el introductory offer activo (debe aparecer "Free for 1 week" en la sección Prices)
- [ ] Monthly tiene el introductory offer activo igual
- [ ] El lifetime está en $39.99 y Family Sharing está enabled
- [ ] Todos los productos están **"Ready to Submit"** o **"Approved"** *(no "Missing Metadata")*
### Estado esperado en App Store Connect
```
Subscriptions:
Group: MealMood Premium
├─ com.alexandrevazquez.mealmood.premium.monthly.sub $2.99 Ready [Intro: Free 1 week]
└─ com.alexandrevazquez.mealmood.premium.yearly.sub $19.99 Ready [Intro: Free 1 week]
In-App Purchases:
└─ com.mealmood.premium.monthly (Non-Consumable Lifetime) $39.99 Ready
```
---
## 5. Test en simulator (sin tocar producción)
El `.storekit` local ya tiene los 3 productos con sus precios y trial. Para probar:
1. Abre `MealMood.xcworkspace` (o el `.xcodeproj`)
2. Edit Scheme → Run → Options → **StoreKit Configuration:** `MealMood.storekit`
3. Run en simulator → abre paywall (Settings → Premium)
4. Deberías ver los 3 planes con el yearly preseleccionado, badge SAVE, lifetime debajo
5. Tap "Start free trial" → sandbox dialog → success → la app marca `isPremium = true`
Para resetear el estado del sandbox:
- En simulator: Debug → StoreKit → Manage Transactions → Delete All
---
## 6. Submit a TestFlight
Una vez los 3 productos estén "Ready to Submit":
```bash
fastlane beta
```
> ️ Apple necesita revisar las suscripciones nuevas + el intro offer junto con la app cuando subas a App Store (no a TestFlight). Para TestFlight no requiere review de IAP.
Cuando submitas a App Store con `fastlane release`, los 3 productos van **incluidos automáticamente** en el submission. Apple los revisa en paralelo con el binario.
+6 -4
View File
@@ -1,7 +1,9 @@
Neu in Version 1.1.4: Neu in Version 1.1.5:
Statistiken (Premium) — neuer Bildschirm mit deiner Wochen-Serie, geplanten Wochen, Abschlussrate, meistgenutzten Gerichten und Kategorienübersicht. 7 Tage kostenlos testen — probiere MealMood Premium ohne Verpflichtung. Neuer Jahresplan mit der größten Ersparnis.
Intelligentere Auto-Zuweisung — der Algorithmus erkennt den natürlichen Rhythmus jedes Gerichts (wöchentlich, zweiwöchentlich, monatlich) und schlägt es genau dann vor, wenn es wieder an der Reihe ist. Verlauf auf 12 Wochen erweitert für bessere Mustererkennung. Klarere Limits — der Gerichte-Zähler zeigt, wie viele du verwendet hast, und die Hinweise beim Erreichen des Limits sind freundlicher und weniger aufdringlich.
Lernschleife — der Algorithmus lernt von dir: ersetzt du ein automatisch zugewiesenes Gericht, wird es in zukünftigen Wochen deprioritisiert. Für vergangene Wochen erscheint eine schnelle Bewertung, damit der Algorithmus Gerichte aus besonders gelungenen Wochen bevorzugt. Neue Premium-Auswahl — wähle Monats- oder Jahresplan nebeneinander, sieh deine Ersparnis und starte den Test mit einem Tipp.
• Weniger Werbung — gelegentliche interne Hinweise ersetzen einige Anzeigen. Mit Premium endgültig entfernen — ein Tipp genügt.
+6 -4
View File
@@ -1,7 +1,9 @@
What's new in 1.1.4: What's new in 1.1.5:
Statistics (Premium) — new screen with your planning streak, weeks planned, completion rate, top dishes and category breakdown. 7-day free trial — try MealMood Premium with no commitment. New yearly plan with the best savings.
• Smarter auto-assign — the algorithm now detects each dish's natural rotation cycle (weekly, bi-weekly, monthly) and suggests it right when it's due. History window extended to 12 weeks for better pattern detection. • Smarter limits — the dish counter now shows how many you've used, the new contextual prompts at limits are clearer and less intrusive.
Feedback loop — the algorithm learns from you: replace an auto-assigned dish and it gets deprioritised in future weeks. For past weeks, a quick like/dislike rating appears so the algorithm can boost dishes from weeks you loved. Better paywall — choose monthly or yearly side by side, see your savings, and start your trial in one tap.
• Cleaner ads experience — occasional internal promos replace some ads. One tap to remove them forever with Premium.
+6 -4
View File
@@ -1,7 +1,9 @@
Novedades en 1.1.4: Novedades en la versión 1.1.5:
Estadísticas (Premium)nueva pantalla con tu racha de semanas, semanas planificadas, tasa de completado, platos más usados y desglose por categoría. Prueba gratis de 7 díasprueba MealMood Premium sin compromiso. Nuevo plan anual con el mayor ahorro.
Auto-asignación más inteligente — el algoritmo detecta el ciclo natural de cada plato (semanal, quincenal, mensual) y lo sugiere justo cuando toca. Historial ampliado a 12 semanas para detectar mejor los patrones. Límites más claros — el contador de platos muestra cuántos has usado, y los avisos al llegar al límite son más amables y menos intrusivos.
Bucle de aprendizaje — el algoritmo aprende de ti: si cambias un plato auto-asignado, lo penaliza en futuras semanas. En semanas pasadas aparece una valoración rápida para que el algoritmo potencie los platos de las semanas que más te gustaron. Paywall renovada — elige plan mensual o anual lado a lado, ve cuánto ahorras y empieza tu prueba en un toque.
• Menos anuncios — promociones internas reemplazan algunos anuncios. Un toque y los eliminas para siempre con Premium.
+6 -4
View File
@@ -1,7 +1,9 @@
Nouveautés dans la version 1.1.4 : Nouveautés dans la version 1.1.5 :
Statistiques (Premium) — nouvel écran avec votre série de semaines, semaines planifiées, taux de complétion, plats les plus utilisés et répartition par catégorie. Essai gratuit de 7 jours — essayez MealMood Premium sans engagement. Nouveau plan annuel avec les meilleures économies.
Auto-assignation plus intelligente — l'algorithme détecte le cycle naturel de chaque plat (hebdomadaire, bimensuel, mensuel) et le propose au bon moment. Historique étendu à 12 semaines pour une meilleure détection des habitudes. Limites plus claires — le compteur de plats indique combien vous en avez utilisé, et les alertes aux limites sont plus douces et moins intrusives.
Boucle d'apprentissage — l'algorithme apprend de vous : remplacez un plat auto-assigné et il sera déprioritisé pour les semaines suivantes. Sur les semaines passées, une notation rapide permet à l'algorithme de valoriser les plats des semaines réussies. Paywall améliorée — choisissez le plan mensuel ou annuel côte à côte, voyez vos économies et démarrez votre essai en un tap.
• Moins de pubs — des promotions internes remplacent certaines pubs. Un tap pour les supprimer définitivement avec Premium.
+6 -4
View File
@@ -1,7 +1,9 @@
Novità nella versione 1.1.4: Novità nella versione 1.1.5:
Statistiche (Premium)nuova schermata con la tua serie di settimane, settimane pianificate, tasso di completamento, piatti più utilizzati e suddivisione per categoria. Prova gratuita di 7 giorniprova MealMood Premium senza impegno. Nuovo piano annuale con il massimo risparmio.
Auto-assegnazione più intelligente — l'algoritmo rileva il ciclo naturale di ogni piatto (settimanale, bisettimanale, mensile) e lo suggerisce esattamente quando è il momento giusto. Cronologia estesa a 12 settimane per un rilevamento dei pattern più accurato. Limiti più chiari — il contatore di piatti mostra quanti ne hai usati, e gli avvisi al raggiungimento del limite sono più gentili e meno invadenti.
Ciclo di apprendimento — l'algoritmo impara da te: se sostituisci un piatto assegnato automaticamente, verrà deprioritizzato nelle settimane future. Per le settimane passate, appare una rapida valutazione che consente all'algoritmo di valorizzare i piatti delle settimane migliori. Pagamento ridisegnato — scegli piano mensile o annuale fianco a fianco, vedi il risparmio e inizia la prova con un tocco.
• Meno pubblicità — promozioni interne occasionali sostituiscono alcune pubblicità. Un tocco per rimuoverle per sempre con Premium.
+6 -4
View File
@@ -1,7 +1,9 @@
Novidades na versão 1.1.4: Novidades na versão 1.1.5:
Estatísticas (Premium) — nova tela com sua sequência de semanas, semanas planejadas, taxa de conclusão, pratos mais usados e divisão por categoria. Teste grátis de 7 dias — experimente o MealMood Premium sem compromisso. Novo plano anual com a maior economia.
Atribuição automática mais inteligente — o algoritmo detecta o ciclo natural de cada prato (semanal, quinzenal, mensal) e o sugere exatamente quando é hora. Histórico ampliado para 12 semanas para melhor detecção de padrões. Limites mais claros — o contador de pratos mostra quantos você já usou, e os avisos ao chegar no limite são mais gentis e menos intrusivos.
Ciclo de aprendizado — o algoritmo aprende com você: substitua um prato atribuído automaticamente e ele será desprioritizado nas semanas seguintes. Para semanas passadas, aparece uma avaliação rápida para que o algoritmo valorize os pratos das semanas que você mais gostou. Tela Premium renovada — escolha plano mensal ou anual lado a lado, veja o quanto economiza e comece seu teste com um toque.
• Menos anúncios — promoções internas substituem alguns anúncios. Um toque para removê-los para sempre com o Premium.