Growth y monetización: fixes críticos, paywall multi-plan, teasers y App Intents
CRÍTICO — enlaces del App Store rotos (adquisición viral = 0): - GoalShareService.appStoreURL apuntaba a id6744983373 (404): el QR de TODAS las imágenes compartidas llevaba a una página muerta. Corregido a id6757678318. - SettingsView enlazaba id6741412965 (una novela romántica). Ahora usa la constante. Monetización: - IAPService multi-producto: sub anual (com.portfoliojournal.premium.annual, con intro offer/trial) + lifetime como ancla. isPremium acepta cualquiera de los dos. Paywall con selector de plan (anual por defecto si existe; degrada a solo lifetime mientras el producto no esté creado en App Store Connect). - paywallBenefits: añadidos Multiple Accounts y Family Sharing (diferenciadores). - Teaser de historia bloqueada en Charts: "N meses más con Premium" en vez de truncar en silencio (FreemiumValidator.hiddenHistoryMonths + banner). - Trigger de paywall de backups ahora instrumentado (logPaywallShown "backups"). Retención / adquisición: - Rating velocity: prompt tras 2 check-ins y 30 días (antes 3 + 90 — en una app mensual eso era >3 meses sin poder pedir la primera review con ~0 ratings). - Sample data ON por defecto en onboarding (skip ya no aterriza en dashboard vacío). - Notificación de protección de streak el día 25 si el check-in del mes está pendiente. - App Intents + AppShortcutsProvider: "Update my portfolio" / "Check my portfolio" vía Siri/Shortcuts/Spotlight. - Instrumentación: onboarding_step/onboarding_skipped y content_shared (viral loop). - ScreenshotMode (--screenshots) para capturas de marketing automatizadas. ASO: - 6 locales nuevos en metadata (en-GB, en-CA, en-AU, es-MX, fr-CA, pt-PT) reusando traducciones — 6 campos de keywords extra; pt-PT adaptado (reforma). - Categoría secundaria: PRODUCTIVITY. - 17 strings nuevas localizadas en los 7 idiomas. Pendiente manual: crear la sub anual en App Store Connect (grupo de suscripción + intro offer 7 días) con el id exacto com.portfoliojournal.premium.annual. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
This commit is contained in:
@@ -1,5 +1,34 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Support for automated App Store screenshot capture. Activated by launching the app
|
||||
/// with the `--screenshots` argument (used by the UI-test capture flow). Only mutates
|
||||
/// state when that argument is present, so it never affects normal runs.
|
||||
enum ScreenshotMode {
|
||||
static var isActive: Bool { CommandLine.arguments.contains("--screenshots") }
|
||||
|
||||
/// Skip onboarding, biometric/PIN lock and the What's New sheet so the app opens
|
||||
/// straight into content. Called very early, before ContentView reads @AppStorage.
|
||||
static func applyDefaultsIfNeeded() {
|
||||
guard isActive else { return }
|
||||
let d = UserDefaults.standard
|
||||
d.set(true, forKey: "onboardingCompleted")
|
||||
d.set(false, forKey: "faceIdEnabled")
|
||||
d.set(false, forKey: "pinEnabled")
|
||||
d.set(false, forKey: "lockOnLaunch")
|
||||
d.set(false, forKey: "lockOnBackground")
|
||||
d.set(false, forKey: "calmModeEnabled") // show all charts for richer screenshots
|
||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||
d.set(version, forKey: "lastSeenWhatsNewVersion")
|
||||
}
|
||||
|
||||
/// Seed demo data (no-op if the store already has sources). Called once Core Data
|
||||
/// has finished loading so the view context is ready.
|
||||
static func seedIfNeeded() {
|
||||
guard isActive else { return }
|
||||
SampleDataService.shared.seedSampleData()
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct PortfolioJournalApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@@ -12,6 +41,10 @@ struct PortfolioJournalApp: App {
|
||||
let coreDataStack = CoreDataStack.shared
|
||||
|
||||
init() {
|
||||
// Screenshot/UI-capture mode: skip onboarding, lock and What's New so the app
|
||||
// launches straight into content with demo data (see ScreenshotMode).
|
||||
ScreenshotMode.applyDefaultsIfNeeded()
|
||||
|
||||
// Clean up any duplicate objects from previous bugs before initializing stores
|
||||
CoreDataStack.shared.cleanupDuplicateObjects()
|
||||
|
||||
@@ -40,6 +73,7 @@ struct PortfolioJournalApp: App {
|
||||
coreDataStack.refreshFromCloudKit()
|
||||
NotificationService.shared.scheduleReEngagementNotification()
|
||||
NotificationService.shared.scheduleMonthlyCheckIn()
|
||||
NotificationService.shared.scheduleStreakProtectionReminder()
|
||||
} else if newPhase == .background {
|
||||
guard iapService.isPremium else { return }
|
||||
guard UserDefaults.standard.bool(forKey: "backupsEnabled") else { return }
|
||||
|
||||
@@ -190,6 +190,8 @@ class CoreDataStack: ObservableObject {
|
||||
// Migrate device-local UserDefaults data into Core Data so it syncs via iCloud.
|
||||
MonthlyContributionStore.migrateIfNeeded(context: container.viewContext)
|
||||
AllocationTargetStore.migrateIfNeeded(context: container.viewContext)
|
||||
// Seed demo data when running in screenshot capture mode.
|
||||
ScreenshotMode.seedIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -414,3 +414,22 @@
|
||||
"insight_forecast_title" = "Auf dem Weg zu";
|
||||
"notification_milestone_title" = "Portfolio-Meilenstein! 🎉";
|
||||
"notification_milestone_body" = "Dein Portfolio hat gerade %@ überschritten. Herzlichen Glückwunsch!";
|
||||
|
||||
"streak_protection_notification_title" = "Brich deine Serie nicht 🔥";
|
||||
"streak_protection_notification_body" = "Dein monatlicher Check-in steht noch aus. 2 Minuten genügen, um deine Serie zu halten.";
|
||||
|
||||
"paywall_benefit_accounts_title" = "Mehrere Konten";
|
||||
"paywall_benefit_accounts_subtitle" = "Getrennte Portfolios für Familie oder Business";
|
||||
"paywall_benefit_family_title" = "Familienfreigabe";
|
||||
"paywall_benefit_family_subtitle" = "Ein Kauf, bis zu 5 Familienmitglieder";
|
||||
"paywall_plan_annual" = "Jährlich";
|
||||
"paywall_plan_annual_per_year" = "%@ / Jahr";
|
||||
"paywall_plan_lifetime" = "Lebenslang";
|
||||
"paywall_plan_lifetime_badge" = "BESTER WERT";
|
||||
"paywall_trial_days" = "Tage";
|
||||
"paywall_trial_weeks" = "Wochen";
|
||||
"paywall_trial_months" = "Monate";
|
||||
"paywall_trial_years" = "Jahre";
|
||||
"paywall_trial_format" = "%d %@ gratis";
|
||||
|
||||
"chart_history_locked" = "%d weitere Monate Verlauf — mit Premium freischalten";
|
||||
|
||||
@@ -422,3 +422,22 @@
|
||||
"insight_forecast_title" = "On track for";
|
||||
"notification_milestone_title" = "Portfolio Milestone! 🎉";
|
||||
"notification_milestone_body" = "Your portfolio just passed %@. Congrats!";
|
||||
|
||||
"streak_protection_notification_title" = "Don't break your streak 🔥";
|
||||
"streak_protection_notification_body" = "Your monthly check-in is still pending. It takes 2 minutes to keep your streak alive.";
|
||||
|
||||
"paywall_benefit_accounts_title" = "Multiple Accounts";
|
||||
"paywall_benefit_accounts_subtitle" = "Separate portfolios for family or business";
|
||||
"paywall_benefit_family_title" = "Family Sharing";
|
||||
"paywall_benefit_family_subtitle" = "One purchase, up to 5 family members";
|
||||
"paywall_plan_annual" = "Annual";
|
||||
"paywall_plan_annual_per_year" = "%@ / year";
|
||||
"paywall_plan_lifetime" = "Lifetime";
|
||||
"paywall_plan_lifetime_badge" = "BEST VALUE";
|
||||
"paywall_trial_days" = "days";
|
||||
"paywall_trial_weeks" = "weeks";
|
||||
"paywall_trial_months" = "months";
|
||||
"paywall_trial_years" = "years";
|
||||
"paywall_trial_format" = "%d %@ free";
|
||||
|
||||
"chart_history_locked" = "%d more months of history — unlock with Premium";
|
||||
|
||||
@@ -366,3 +366,22 @@
|
||||
"insight_forecast_title" = "En camino hacia";
|
||||
"notification_milestone_title" = "¡Hito de cartera! 🎉";
|
||||
"notification_milestone_body" = "Tu cartera acaba de superar %@. ¡Enhorabuena!";
|
||||
|
||||
"streak_protection_notification_title" = "No rompas tu racha 🔥";
|
||||
"streak_protection_notification_body" = "Aún tienes pendiente el check-in de este mes. Son 2 minutos y tu racha sigue viva.";
|
||||
|
||||
"paywall_benefit_accounts_title" = "Varias cuentas";
|
||||
"paywall_benefit_accounts_subtitle" = "Carteras separadas para familia o negocio";
|
||||
"paywall_benefit_family_title" = "En familia";
|
||||
"paywall_benefit_family_subtitle" = "Una compra, hasta 5 miembros de la familia";
|
||||
"paywall_plan_annual" = "Anual";
|
||||
"paywall_plan_annual_per_year" = "%@ / año";
|
||||
"paywall_plan_lifetime" = "Para siempre";
|
||||
"paywall_plan_lifetime_badge" = "MEJOR VALOR";
|
||||
"paywall_trial_days" = "días";
|
||||
"paywall_trial_weeks" = "semanas";
|
||||
"paywall_trial_months" = "meses";
|
||||
"paywall_trial_years" = "años";
|
||||
"paywall_trial_format" = "%d %@ gratis";
|
||||
|
||||
"chart_history_locked" = "%d meses más de historial — desbloquéalos con Premium";
|
||||
|
||||
@@ -414,3 +414,22 @@
|
||||
"insight_forecast_title" = "En bonne voie pour";
|
||||
"notification_milestone_title" = "Jalon de portefeuille ! 🎉";
|
||||
"notification_milestone_body" = "Votre portefeuille vient de dépasser %@. Félicitations !";
|
||||
|
||||
"streak_protection_notification_title" = "Ne brisez pas votre série 🔥";
|
||||
"streak_protection_notification_body" = "Votre bilan mensuel est encore en attente. 2 minutes suffisent pour garder votre série.";
|
||||
|
||||
"paywall_benefit_accounts_title" = "Comptes multiples";
|
||||
"paywall_benefit_accounts_subtitle" = "Des portefeuilles séparés pour la famille ou le travail";
|
||||
"paywall_benefit_family_title" = "Partage familial";
|
||||
"paywall_benefit_family_subtitle" = "Un achat, jusqu'à 5 membres de la famille";
|
||||
"paywall_plan_annual" = "Annuel";
|
||||
"paywall_plan_annual_per_year" = "%@ / an";
|
||||
"paywall_plan_lifetime" = "À vie";
|
||||
"paywall_plan_lifetime_badge" = "MEILLEURE OFFRE";
|
||||
"paywall_trial_days" = "jours";
|
||||
"paywall_trial_weeks" = "semaines";
|
||||
"paywall_trial_months" = "mois";
|
||||
"paywall_trial_years" = "ans";
|
||||
"paywall_trial_format" = "%d %@ gratuits";
|
||||
|
||||
"chart_history_locked" = "%d mois d'historique en plus — débloquez avec Premium";
|
||||
|
||||
@@ -414,3 +414,22 @@
|
||||
"insight_forecast_title" = "In direzione di";
|
||||
"notification_milestone_title" = "Traguardo raggiunto! 🎉";
|
||||
"notification_milestone_body" = "Il tuo portafoglio ha appena superato %@. Complimenti!";
|
||||
|
||||
"streak_protection_notification_title" = "Non interrompere la tua serie 🔥";
|
||||
"streak_protection_notification_body" = "Il check-in di questo mese è ancora in sospeso. Bastano 2 minuti per mantenere viva la serie.";
|
||||
|
||||
"paywall_benefit_accounts_title" = "Più account";
|
||||
"paywall_benefit_accounts_subtitle" = "Portafogli separati per famiglia o lavoro";
|
||||
"paywall_benefit_family_title" = "In famiglia";
|
||||
"paywall_benefit_family_subtitle" = "Un acquisto, fino a 5 membri della famiglia";
|
||||
"paywall_plan_annual" = "Annuale";
|
||||
"paywall_plan_annual_per_year" = "%@ / anno";
|
||||
"paywall_plan_lifetime" = "Per sempre";
|
||||
"paywall_plan_lifetime_badge" = "MIGLIOR VALORE";
|
||||
"paywall_trial_days" = "giorni";
|
||||
"paywall_trial_weeks" = "settimane";
|
||||
"paywall_trial_months" = "mesi";
|
||||
"paywall_trial_years" = "anni";
|
||||
"paywall_trial_format" = "%d %@ gratis";
|
||||
|
||||
"chart_history_locked" = "%d mesi di storico in più — sblocca con Premium";
|
||||
|
||||
@@ -414,3 +414,22 @@
|
||||
"insight_forecast_title" = "目標に向けて順調";
|
||||
"notification_milestone_title" = "ポートフォリオ達成!🎉";
|
||||
"notification_milestone_body" = "ポートフォリオが%@を突破しました。おめでとうございます!";
|
||||
|
||||
"streak_protection_notification_title" = "連続記録を守ろう 🔥";
|
||||
"streak_protection_notification_body" = "今月のチェックインがまだ完了していません。2分で連続記録を守れます。";
|
||||
|
||||
"paywall_benefit_accounts_title" = "複数アカウント";
|
||||
"paywall_benefit_accounts_subtitle" = "家族用・事業用にポートフォリオを分けて管理";
|
||||
"paywall_benefit_family_title" = "ファミリー共有";
|
||||
"paywall_benefit_family_subtitle" = "1回の購入で家族5人まで利用可能";
|
||||
"paywall_plan_annual" = "年額";
|
||||
"paywall_plan_annual_per_year" = "%@ / 年";
|
||||
"paywall_plan_lifetime" = "買い切り";
|
||||
"paywall_plan_lifetime_badge" = "ベストバリュー";
|
||||
"paywall_trial_days" = "日間";
|
||||
"paywall_trial_weeks" = "週間";
|
||||
"paywall_trial_months" = "か月";
|
||||
"paywall_trial_years" = "年間";
|
||||
"paywall_trial_format" = "%d%@無料";
|
||||
|
||||
"chart_history_locked" = "さらに%dか月分の履歴 — プレミアムで解除";
|
||||
|
||||
@@ -414,3 +414,22 @@
|
||||
"insight_forecast_title" = "Caminhando para";
|
||||
"notification_milestone_title" = "Meta de portfólio! 🎉";
|
||||
"notification_milestone_body" = "Seu portfólio acabou de ultrapassar %@. Parabéns!";
|
||||
|
||||
"streak_protection_notification_title" = "Não quebre sua sequência 🔥";
|
||||
"streak_protection_notification_body" = "O check-in deste mês ainda está pendente. Leva 2 minutos para manter sua sequência viva.";
|
||||
|
||||
"paywall_benefit_accounts_title" = "Várias contas";
|
||||
"paywall_benefit_accounts_subtitle" = "Carteiras separadas para família ou negócios";
|
||||
"paywall_benefit_family_title" = "Compartilhamento Familiar";
|
||||
"paywall_benefit_family_subtitle" = "Uma compra, até 5 membros da família";
|
||||
"paywall_plan_annual" = "Anual";
|
||||
"paywall_plan_annual_per_year" = "%@ / ano";
|
||||
"paywall_plan_lifetime" = "Vitalício";
|
||||
"paywall_plan_lifetime_badge" = "MELHOR VALOR";
|
||||
"paywall_trial_days" = "dias";
|
||||
"paywall_trial_weeks" = "semanas";
|
||||
"paywall_trial_months" = "meses";
|
||||
"paywall_trial_years" = "anos";
|
||||
"paywall_trial_format" = "%d %@ grátis";
|
||||
|
||||
"chart_history_locked" = "%d meses a mais de histórico — desbloqueie com o Premium";
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import AppIntents
|
||||
import Foundation
|
||||
|
||||
// MARK: - App Intents (Siri / Shortcuts / Spotlight)
|
||||
//
|
||||
// Surfaces the core monthly habit outside the app: "Update my portfolio" opens
|
||||
// Quick Update directly, "Check my portfolio" opens the dashboard. Registering an
|
||||
// AppShortcutsProvider also lists these actions in Spotlight and the Shortcuts app
|
||||
// with zero user setup — a discovery/retention surface the app didn't have.
|
||||
|
||||
struct QuickUpdateIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Update Portfolio"
|
||||
static let description = IntentDescription("Open Quick Update to record your latest portfolio values.")
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
// Same signal the widget deep link uses; ContentView routes it to the sheet.
|
||||
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenDashboardIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Check Portfolio"
|
||||
static let description = IntentDescription("Open the dashboard to see your net worth and charts.")
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
NotificationCenter.default.post(name: .openDashboard, object: nil)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct PortfolioJournalShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(
|
||||
intent: QuickUpdateIntent(),
|
||||
phrases: [
|
||||
"Update my portfolio in \(.applicationName)",
|
||||
"Add a snapshot in \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Update Portfolio",
|
||||
systemImageName: "plus.circle.fill"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: OpenDashboardIntent(),
|
||||
phrases: [
|
||||
"Check my portfolio in \(.applicationName)",
|
||||
"Show my net worth in \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Check Portfolio",
|
||||
systemImageName: "chart.line.uptrend.xyaxis"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,31 @@ class FirebaseService {
|
||||
])
|
||||
}
|
||||
|
||||
/// Per-step onboarding funnel event — lets us see exactly where users drop off
|
||||
/// inside onboarding (only the final `onboarding_completed` existed before).
|
||||
func logOnboardingStep(step: Int) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("onboarding_step", parameters: [
|
||||
"step": step
|
||||
])
|
||||
}
|
||||
|
||||
func logOnboardingSkipped(atStep step: Int) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("onboarding_skipped", parameters: [
|
||||
"step": step
|
||||
])
|
||||
}
|
||||
|
||||
/// Viral loop measurement: fired whenever the user opens a share sheet with
|
||||
/// app content (portfolio image, check-in, goal). `type` identifies the surface.
|
||||
func logShare(type: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("content_shared", parameters: [
|
||||
"share_type": type
|
||||
])
|
||||
}
|
||||
|
||||
func logWidgetUsed(widgetType: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("widget_used", parameters: [
|
||||
|
||||
@@ -7,7 +7,9 @@ class GoalShareService {
|
||||
static let shared = GoalShareService()
|
||||
|
||||
/// App Store URL - auto-redirects to user's country
|
||||
static let appStoreURL = URL(string: "https://apps.apple.com/app/portfolio-journal/id6744983373")!
|
||||
/// NOTE: 6757678318 is the real ADAM id (verified via App Store Connect API).
|
||||
/// The previous id (6744983373) returned a 404, breaking the share QR loop.
|
||||
static let appStoreURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318")!
|
||||
|
||||
/// Website URL - has Open Graph tags for social media previews
|
||||
static let websiteURL = URL(string: "https://portfoliojournal.app")!
|
||||
@@ -58,6 +60,7 @@ class GoalShareService {
|
||||
estimatedCompletionDate: Date? = nil,
|
||||
privacyMode: Bool = false
|
||||
) {
|
||||
FirebaseService.shared.logShare(type: "goal")
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let viewController = windowScene.windows.first?.rootViewController else {
|
||||
return
|
||||
|
||||
@@ -16,7 +16,13 @@ class IAPService: ObservableObject {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
/// Lifetime unlock (non-consumable). Existing product — keep the id stable.
|
||||
static let premiumProductID = "com.portfoliojournal.premium"
|
||||
/// Annual auto-renewable subscription with intro free trial. Acts as the primary
|
||||
/// offer; lifetime becomes the "best value" anchor. Must be created in App Store
|
||||
/// Connect with this exact id — until then the paywall gracefully shows lifetime only.
|
||||
static let annualProductID = "com.portfoliojournal.premium.annual"
|
||||
static let allProductIDs: Set<String> = [premiumProductID, annualProductID]
|
||||
static let premiumPrice = "€4.69"
|
||||
|
||||
// MARK: - Private Properties
|
||||
@@ -64,7 +70,7 @@ class IAPService: ObservableObject {
|
||||
|
||||
func loadProducts() async {
|
||||
do {
|
||||
products = try await Product.products(for: [Self.premiumProductID])
|
||||
products = try await Product.products(for: Self.allProductIDs)
|
||||
print("Loaded \(products.count) products")
|
||||
} catch {
|
||||
print("Failed to load products: \(error)")
|
||||
@@ -73,10 +79,16 @@ class IAPService: ObservableObject {
|
||||
|
||||
// MARK: - Purchase
|
||||
|
||||
/// Purchases the lifetime unlock (backwards-compatible entry point).
|
||||
func purchase() async throws {
|
||||
guard let product = products.first else {
|
||||
guard let product = premiumProduct ?? products.first else {
|
||||
throw IAPError.productNotFound
|
||||
}
|
||||
try await purchase(product)
|
||||
}
|
||||
|
||||
/// Purchases a specific product (annual subscription or lifetime unlock).
|
||||
func purchase(_ product: Product) async throws {
|
||||
|
||||
purchaseState = .purchasing
|
||||
|
||||
@@ -169,10 +181,13 @@ class IAPService: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
var entitledProductID = Self.premiumProductID
|
||||
for await result in StoreKit.Transaction.currentEntitlements {
|
||||
if case .verified(let transaction) = result {
|
||||
if transaction.productID == Self.premiumProductID {
|
||||
// Lifetime unlock OR an active annual subscription both grant premium.
|
||||
if Self.allProductIDs.contains(transaction.productID) {
|
||||
isEntitled = true
|
||||
entitledProductID = transaction.productID
|
||||
familyShared = transaction.ownershipType == .familyShared
|
||||
break
|
||||
}
|
||||
@@ -187,7 +202,7 @@ class IAPService: ObservableObject {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
PremiumStatus.updateStatus(
|
||||
isPremium: isEntitled,
|
||||
productIdentifier: Self.premiumProductID,
|
||||
productIdentifier: entitledProductID,
|
||||
transactionId: nil,
|
||||
isFamilyShared: familyShared,
|
||||
in: context
|
||||
@@ -238,9 +253,34 @@ class IAPService: ObservableObject {
|
||||
products.first { $0.id == Self.premiumProductID }
|
||||
}
|
||||
|
||||
/// Annual subscription, nil until the product exists in App Store Connect.
|
||||
var annualProduct: Product? {
|
||||
products.first { $0.id == Self.annualProductID }
|
||||
}
|
||||
|
||||
var formattedPrice: String {
|
||||
premiumProduct?.displayPrice ?? Self.premiumPrice
|
||||
}
|
||||
|
||||
var formattedAnnualPrice: String? {
|
||||
annualProduct?.displayPrice
|
||||
}
|
||||
|
||||
/// Localized description of the annual intro offer ("7 days free"), if configured.
|
||||
var annualTrialDescription: String? {
|
||||
guard let intro = annualProduct?.subscription?.introductoryOffer,
|
||||
intro.paymentMode == .freeTrial else { return nil }
|
||||
let period = intro.period
|
||||
let unit: String
|
||||
switch period.unit {
|
||||
case .day: unit = String(localized: "paywall_trial_days")
|
||||
case .week: unit = String(localized: "paywall_trial_weeks")
|
||||
case .month: unit = String(localized: "paywall_trial_months")
|
||||
case .year: unit = String(localized: "paywall_trial_years")
|
||||
@unknown default: unit = ""
|
||||
}
|
||||
return String(format: String(localized: "paywall_trial_format"), period.value, unit)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IAP Error
|
||||
@@ -276,7 +316,8 @@ extension IAPService {
|
||||
("person.2", "Family Sharing", "Share with up to 5 family members")
|
||||
]
|
||||
|
||||
/// Condensed benefits shown on the paywall (4 max, outcome-focused)
|
||||
/// Condensed benefits shown on the paywall (outcome-focused). Multiple Accounts and
|
||||
/// Family Sharing are key differentiators vs competitors — keep them visible here.
|
||||
static var paywallBenefits: [(icon: String, title: String, subtitle: String)] {[
|
||||
("clock.arrow.circlepath",
|
||||
String(localized: "paywall_benefit_history_title"),
|
||||
@@ -287,6 +328,12 @@ extension IAPService {
|
||||
("wand.and.stars",
|
||||
String(localized: "paywall_benefit_forecasts_title"),
|
||||
String(localized: "paywall_benefit_forecasts_subtitle")),
|
||||
("person.2",
|
||||
String(localized: "paywall_benefit_accounts_title"),
|
||||
String(localized: "paywall_benefit_accounts_subtitle")),
|
||||
("person.3",
|
||||
String(localized: "paywall_benefit_family_title"),
|
||||
String(localized: "paywall_benefit_family_subtitle")),
|
||||
("xmark.circle",
|
||||
String(localized: "paywall_benefit_noads_title"),
|
||||
String(localized: "paywall_benefit_noads_subtitle"))
|
||||
|
||||
@@ -260,6 +260,44 @@ extension NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Streak protection: reminds the user on the 25th of the CURRENT month if this
|
||||
/// month's check-in is still pending — loss aversion beats the day-1 nudge alone.
|
||||
/// Safe to call on every app activation (no-op if already done or date passed).
|
||||
func scheduleStreakProtectionReminder() {
|
||||
guard isAuthorized else { return }
|
||||
|
||||
let identifier = "streak_protection"
|
||||
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
guard let thisMonthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) else { return }
|
||||
|
||||
// Already checked in this month → nothing to protect
|
||||
guard MonthlyCheckInStore.completionDate(for: thisMonthStart.adding(days: 1)) == nil else { return }
|
||||
|
||||
var components = calendar.dateComponents([.year, .month], from: thisMonthStart)
|
||||
components.day = 25
|
||||
components.hour = 18
|
||||
components.minute = 0
|
||||
guard let fireDate = calendar.date(from: components), fireDate > now else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = String(localized: "streak_protection_notification_title")
|
||||
content.body = String(localized: "streak_protection_notification_body")
|
||||
content.sound = .default
|
||||
content.userInfo = ["action": "batchUpdate"]
|
||||
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
|
||||
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
||||
|
||||
center.add(request) { error in
|
||||
if let error = error {
|
||||
print("Streak protection notification error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules a monthly portfolio summary notification on the 5th of each month at 9am.
|
||||
func scheduleMonthlyPerformanceSummary() {
|
||||
guard isAuthorized else { return }
|
||||
|
||||
@@ -8,8 +8,11 @@ final class ReviewPromptService {
|
||||
private let checkInCountKey = "reviewPromptCheckinCount"
|
||||
private let hasCompletedStoreReviewKey = "reviewPromptHasCompletedStoreReview"
|
||||
private let promptedAchievementKeysKey = "reviewPromptedAchievementKeys"
|
||||
private let minCheckInsBetweenPrompts = 3
|
||||
private let minDaysBetweenPrompts = 90
|
||||
// Rating velocity: with a monthly-cadence app, 3 check-ins + 90 days meant 3+ months
|
||||
// before the first review could even be requested. 2 check-ins + 30 days keeps the
|
||||
// prompt tied to a good moment (a completed check-in) while building ratings sooner.
|
||||
private let minCheckInsBetweenPrompts = 2
|
||||
private let minDaysBetweenPrompts = 30
|
||||
private let userDefaults: UserDefaults
|
||||
private let dateProvider: () -> Date
|
||||
private let reviewRequestHandler: () -> Void
|
||||
|
||||
@@ -48,6 +48,7 @@ class ShareService {
|
||||
|
||||
@MainActor
|
||||
func shareMonthlyCheckIn(summary: MonthlySummary, appName: String) {
|
||||
FirebaseService.shared.logShare(type: "monthly_checkin")
|
||||
let text = Self.buildMonthlyCheckInShareText(summary: summary, appName: appName)
|
||||
shareCard(
|
||||
cardTitle: "\(summary.periodLabel) Check-in",
|
||||
@@ -69,6 +70,7 @@ class ShareService {
|
||||
yearChange: String?,
|
||||
sinceInceptionChange: String?
|
||||
) {
|
||||
FirebaseService.shared.logShare(type: "portfolio_value")
|
||||
let appName = Self.appDisplayName
|
||||
let text = Self.buildPortfolioValueShareText(
|
||||
totalValue: totalValue,
|
||||
|
||||
@@ -54,6 +54,27 @@ class FreemiumValidator: ObservableObject {
|
||||
return snapshots.filter { $0.date >= cutoffDate }
|
||||
}
|
||||
|
||||
/// Number of distinct months of data that exist BEYOND the free history window.
|
||||
/// Used to make the locked value concrete ("8 more months with Premium") instead
|
||||
/// of silently truncating — users can't want what they can't see.
|
||||
func hiddenHistoryMonths(in snapshots: [Snapshot]) -> Int {
|
||||
if iapService.isPremium { return 0 }
|
||||
|
||||
let cutoffDate = Calendar.current.date(
|
||||
byAdding: .month,
|
||||
value: -FreemiumLimits.maxHistoricalMonths,
|
||||
to: Date()
|
||||
) ?? Date()
|
||||
|
||||
let calendar = Calendar.current
|
||||
let hiddenMonths = Set(
|
||||
snapshots
|
||||
.filter { $0.date < cutoffDate }
|
||||
.map { calendar.dateComponents([.year, .month], from: $0.date) }
|
||||
)
|
||||
return hiddenMonths.count
|
||||
}
|
||||
|
||||
func isSnapshotAccessible(_ snapshot: Snapshot) -> Bool {
|
||||
if iapService.isPremium { return true }
|
||||
|
||||
|
||||
@@ -121,6 +121,9 @@ class ChartsViewModel: ObservableObject {
|
||||
@Published var predictionData: [Prediction] = []
|
||||
@Published var allocationEvolutionData: [(date: Date, category: String, percentage: Double, color: String)] = []
|
||||
@Published var yearOverYearData: [YearSeries] = []
|
||||
/// Months of data hidden by the free-tier 12-month history limit (0 for premium).
|
||||
/// Drives the "unlock X more months" teaser in the charts screen.
|
||||
@Published var hiddenHistoryMonths: Int = 0
|
||||
@Published var yoySelectedYears: Set<Int> = []
|
||||
|
||||
struct YearSeries: Identifiable {
|
||||
@@ -456,6 +459,7 @@ class ChartsViewModel: ObservableObject {
|
||||
for: sourceIds,
|
||||
months: monthsLimit
|
||||
)
|
||||
hiddenHistoryMonths = freemiumValidator.hiddenHistoryMonths(in: snapshots)
|
||||
snapshots = freemiumValidator.filterSnapshots(snapshots)
|
||||
cachedSnapshots = snapshots
|
||||
}
|
||||
|
||||
@@ -538,6 +538,7 @@ class SettingsViewModel: ObservableObject {
|
||||
guard isPremium else {
|
||||
backupsEnabled = false
|
||||
UserDefaults.standard.set(false, forKey: backupsEnabledKey)
|
||||
FirebaseService.shared.logPaywallShown(trigger: "backups")
|
||||
showingPaywall = true
|
||||
return
|
||||
}
|
||||
|
||||
@@ -141,6 +141,9 @@ struct ChartsContainerView: View {
|
||||
}
|
||||
}
|
||||
chartContent
|
||||
if viewModel.hiddenHistoryMonths > 0 {
|
||||
lockedHistoryTeaser
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@@ -150,6 +153,30 @@ struct ChartsContainerView: View {
|
||||
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
|
||||
}
|
||||
|
||||
/// Concrete loss-framing upsell: the user has real data older than the free
|
||||
/// 12-month window; tell them exactly how much is locked instead of hiding it.
|
||||
private var lockedHistoryTeaser: some View {
|
||||
Button {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "history_teaser")
|
||||
viewModel.showingPaywall = true
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "lock.fill")
|
||||
Text(String(format: String(localized: "chart_history_locked"), viewModel.hiddenHistoryMonths))
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
}
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundColor(.appPrimary)
|
||||
.padding(12)
|
||||
.background(Color.appPrimary.opacity(0.08))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - iPhone Layout
|
||||
|
||||
private var iPhoneChartsLayout: some View {
|
||||
@@ -166,6 +193,9 @@ struct ChartsContainerView: View {
|
||||
}
|
||||
filtersSection
|
||||
chartContent
|
||||
if viewModel.hiddenHistoryMonths > 0 {
|
||||
lockedHistoryTeaser
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ struct OnboardingView: View {
|
||||
|
||||
@State private var currentPage = 0
|
||||
@State private var selectedCurrency = Locale.current.currency?.identifier ?? "EUR"
|
||||
@State private var useSampleData = false
|
||||
// Default ON: a user who skips onboarding lands on a populated dashboard (instant
|
||||
// aha moment) instead of an empty screen. Sample data is clearly labeled and removable.
|
||||
@State private var useSampleData = true
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
|
||||
@State private var showingImportSheet = false
|
||||
@@ -90,6 +92,7 @@ struct OnboardingView: View {
|
||||
}
|
||||
|
||||
Button {
|
||||
FirebaseService.shared.logOnboardingSkipped(atStep: currentPage)
|
||||
completeOnboarding()
|
||||
} label: {
|
||||
Text("Skip")
|
||||
@@ -117,6 +120,9 @@ struct OnboardingView: View {
|
||||
.onAppear {
|
||||
selectedCurrency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
}
|
||||
.onChange(of: currentPage) { _, newPage in
|
||||
FirebaseService.shared.logOnboardingStep(step: newPage)
|
||||
}
|
||||
.sheet(isPresented: $showingImportSheet) {
|
||||
ImportDataView(importContext: .onboarding)
|
||||
}
|
||||
@@ -220,6 +226,12 @@ struct OnboardingQuickStartView: View {
|
||||
let onImport: () -> Void
|
||||
let onAddSource: () -> Void
|
||||
|
||||
/// URL to the app's page in the system Settings app. Exposed as a static helper
|
||||
/// for testability (see OnboardingViewTests).
|
||||
static func appSettingsURL() -> URL? {
|
||||
URL(string: UIApplication.openSettingsURLString)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 28) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
|
||||
// MARK: - Chart Preview (decorative, no real data)
|
||||
|
||||
@@ -96,6 +97,11 @@ struct PaywallView: View {
|
||||
@State private var errorMessage: String?
|
||||
@State private var showingError = false
|
||||
|
||||
/// Which plan the user has highlighted. Defaults to annual (primary offer) when
|
||||
/// the subscription exists in App Store Connect; falls back to lifetime otherwise.
|
||||
enum Plan { case annual, lifetime }
|
||||
@State private var selectedPlan: Plan = .lifetime
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
AppBackground()
|
||||
@@ -116,7 +122,11 @@ struct PaywallView: View {
|
||||
|
||||
// Bottom actions
|
||||
VStack(spacing: 12) {
|
||||
priceLabel
|
||||
if iapService.annualProduct != nil {
|
||||
planSelector
|
||||
} else {
|
||||
priceLabel
|
||||
}
|
||||
purchaseButton
|
||||
restoreButton
|
||||
legalSection
|
||||
@@ -197,6 +207,72 @@ struct PaywallView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Plan Selector
|
||||
|
||||
/// Two-option selector: annual subscription (primary, with free trial when
|
||||
/// configured) and lifetime unlock as the "best value" anchor.
|
||||
private var planSelector: some View {
|
||||
VStack(spacing: 8) {
|
||||
if let annualPrice = iapService.formattedAnnualPrice {
|
||||
planRow(
|
||||
plan: .annual,
|
||||
title: String(localized: "paywall_plan_annual"),
|
||||
price: String(format: String(localized: "paywall_plan_annual_per_year"), annualPrice),
|
||||
badge: iapService.annualTrialDescription
|
||||
)
|
||||
}
|
||||
planRow(
|
||||
plan: .lifetime,
|
||||
title: String(localized: "paywall_plan_lifetime"),
|
||||
price: iapService.formattedPrice,
|
||||
badge: String(localized: "paywall_plan_lifetime_badge")
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
if iapService.annualProduct != nil { selectedPlan = .annual }
|
||||
}
|
||||
}
|
||||
|
||||
private func planRow(plan: Plan, title: String, price: String, badge: String?) -> some View {
|
||||
Button {
|
||||
selectedPlan = plan
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
if let badge, !badge.isEmpty {
|
||||
Text(badge)
|
||||
.font(.caption2.weight(.bold))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.appSecondary.opacity(0.15))
|
||||
.foregroundColor(.appSecondary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
Text(price)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: selectedPlan == plan ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundColor(selectedPlan == plan ? .appPrimary : .secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(.systemBackground))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius)
|
||||
.stroke(selectedPlan == plan ? Color.appPrimary : Color.gray.opacity(0.25),
|
||||
lineWidth: selectedPlan == plan ? 2 : 1)
|
||||
)
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Purchase Button
|
||||
|
||||
private var purchaseButton: some View {
|
||||
@@ -259,11 +335,18 @@ struct PaywallView: View {
|
||||
|
||||
private func purchase() {
|
||||
isPurchasing = true
|
||||
FirebaseService.shared.logPurchaseAttempt(productId: IAPService.premiumProductID)
|
||||
let product = (selectedPlan == .annual ? iapService.annualProduct : iapService.premiumProduct)
|
||||
FirebaseService.shared.logPurchaseAttempt(
|
||||
productId: product?.id ?? IAPService.premiumProductID
|
||||
)
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await iapService.purchase()
|
||||
if let product {
|
||||
try await iapService.purchase(product)
|
||||
} else {
|
||||
try await iapService.purchase()
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showingError = true
|
||||
|
||||
@@ -194,7 +194,7 @@ struct SettingsView: View {
|
||||
|
||||
private var updateAvailableSection: some View {
|
||||
Section {
|
||||
Link(destination: URL(string: "https://apps.apple.com/app/id6741412965")!) {
|
||||
Link(destination: GoalShareService.appStoreURL) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.foregroundColor(.appPrimary)
|
||||
|
||||
Reference in New Issue
Block a user