Files
InvestmentTrackerApp/PortfolioJournal/Views/Onboarding/OnboardingView.swift
T
alexandrev-tibco 197bddcd7e 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
2026-07-02 22:12:32 +02:00

377 lines
13 KiB
Swift

import SwiftUI
import UIKit
struct OnboardingView: View {
@Binding var onboardingCompleted: Bool
@State private var currentPage = 0
@State private var selectedCurrency = Locale.current.currency?.identifier ?? "EUR"
// 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
@State private var showingAddSource = false
private let pages: [OnboardingPage] = [
OnboardingPage(
icon: "chart.line.uptrend.xyaxis",
title: String(localized: "onboarding_clarity_title"),
description: String(localized: "onboarding_clarity_desc"),
color: .appPrimary
),
OnboardingPage(
icon: "checkmark.circle.fill",
title: String(localized: "onboarding_habit_title"),
description: String(localized: "onboarding_habit_desc"),
color: .positiveGreen
),
OnboardingPage(
icon: "leaf.fill",
title: String(localized: "onboarding_calm_title"),
description: String(localized: "onboarding_calm_desc"),
color: .appSecondary
),
OnboardingPage(
icon: "flag.checkered",
title: String(localized: "onboarding_goals_title"),
description: String(localized: "onboarding_goals_desc"),
color: .appWarning
)
]
var body: some View {
VStack {
// Pages
TabView(selection: $currentPage) {
ForEach(0..<pages.count, id: \.self) { index in
OnboardingPageView(page: pages[index])
.tag(index)
}
OnboardingQuickStartView(
selectedCurrency: $selectedCurrency,
useSampleData: $useSampleData,
calmModeEnabled: $calmModeEnabled,
cloudSyncEnabled: $cloudSyncEnabled,
onImport: { showingImportSheet = true },
onAddSource: { showingAddSource = true }
)
.tag(pages.count)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.animation(.easeInOut, value: currentPage)
// Page indicators
HStack(spacing: 8) {
ForEach(0..<(pages.count + 1), id: \.self) { index in
Circle()
.fill(currentPage == index ? Color.appPrimary : Color.gray.opacity(0.3))
.frame(width: 8, height: 8)
.animation(.easeInOut, value: currentPage)
}
}
.padding(.bottom, 20)
// Buttons
VStack(spacing: 12) {
if currentPage < pages.count {
Button {
withAnimation {
currentPage += 1
}
} label: {
Text("Continue")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
}
Button {
FirebaseService.shared.logOnboardingSkipped(atStep: currentPage)
completeOnboarding()
} label: {
Text("Skip")
.font(.subheadline)
.foregroundColor(.secondary)
}
} else {
Button {
completeOnboarding()
} label: {
Text("Get Started")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
}
}
}
.padding(.horizontal, 24)
.padding(.bottom, 40)
}
.background(AppBackground())
.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)
}
.sheet(isPresented: $showingAddSource) {
AddSourceView()
}
}
private func completeOnboarding() {
// Create default categories
let categoryRepository = CategoryRepository()
categoryRepository.createDefaultCategoriesIfNeeded()
// Create default account if needed
AccountRepository().createDefaultAccountIfNeeded()
// Mark onboarding as complete
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.currency = selectedCurrency
settings.onboardingCompleted = true
CoreDataStack.shared.save()
if useSampleData {
SampleDataService.shared.seedSampleData()
}
// Log analytics
FirebaseService.shared.logOnboardingCompleted(stepCount: pages.count + 1)
// Request notification permission
Task {
await NotificationService.shared.requestAuthorization()
}
withAnimation {
onboardingCompleted = true
}
}
}
// MARK: - Onboarding Page Model
struct OnboardingPage {
let icon: String
let title: String
let description: String
let color: Color
}
// MARK: - Onboarding Page View
struct OnboardingPageView: View {
let page: OnboardingPage
var body: some View {
VStack(spacing: 32) {
Spacer()
// Icon
ZStack {
Circle()
.fill(page.color.opacity(0.15))
.frame(width: 140, height: 140)
Circle()
.fill(page.color.opacity(0.3))
.frame(width: 100, height: 100)
Image(systemName: page.icon)
.font(.system(size: 50))
.foregroundColor(page.color)
}
// Content
VStack(spacing: 16) {
Text(page.title)
.font(.title.weight(.bold))
.multilineTextAlignment(.center)
Text(page.description)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
Spacer()
Spacer()
}
}
}
// MARK: - Quick Start
struct OnboardingQuickStartView: View {
@Binding var selectedCurrency: String
@Binding var useSampleData: Bool
@Binding var calmModeEnabled: Bool
@Binding var cloudSyncEnabled: Bool
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) {
Spacer().frame(height: 8)
// Header
VStack(spacing: 10) {
Image("BrandMark")
.resizable()
.scaledToFit()
.frame(width: 64, height: 64)
Text(String(localized: "onboarding_quickstart_title"))
.font(.title.weight(.bold))
Text(String(localized: "onboarding_quickstart_subtitle"))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 30)
}
// Primary CTA Add first source
VStack(spacing: 10) {
Button(action: onAddSource) {
Label(String(localized: "onboarding_add_first_source"), systemImage: "plus.circle.fill")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(AppConstants.UI.cornerRadius)
}
Button(action: onImport) {
Label(String(localized: "onboarding_import_data"), systemImage: "square.and.arrow.down")
.font(.subheadline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.appPrimary.opacity(0.1))
.foregroundColor(.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
}
}
// Secondary options
VStack(spacing: 10) {
HStack {
Text("Currency")
Spacer()
Picker("Currency", selection: $selectedCurrency) {
ForEach(CurrencyPicker.commonCodes, id: \.self) { code in
Text(code).tag(code)
}
}
.pickerStyle(.menu)
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
VStack(alignment: .leading, spacing: 6) {
Toggle("Focus Mode (recommended)", isOn: $calmModeEnabled)
Text("Shows returns since your last check-in instead of daily noise. You can change this anytime in Settings.")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
Toggle("Sync with iCloud (optional)", isOn: $cloudSyncEnabled)
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
}
Spacer().frame(height: 8)
}
.padding(.horizontal, 24)
}
}
}
// MARK: - First Launch Welcome
struct WelcomeView: View {
@Binding var showWelcome: Bool
let userName: String?
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
var body: some View {
VStack(spacing: 24) {
Spacer()
Image(systemName: "hand.wave.fill")
.font(.system(size: 60))
.foregroundColor(.appPrimary)
VStack(spacing: 8) {
if let name = userName {
Text("Welcome back, \(name)!")
.font(.title.weight(.bold))
} else {
Text("Welcome!")
.font(.title.weight(.bold))
}
Text(cloudSyncEnabled
? "Your investment data has been synced from iCloud."
: "Your investment data stays on this device.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
Spacer()
Button {
withAnimation {
showWelcome = false
}
} label: {
Text("Continue")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
}
.padding(.horizontal, 24)
.padding(.bottom, 40)
}
.background(Color(.systemBackground))
}
}
#Preview {
OnboardingView(onboardingCompleted: .constant(false))
.environmentObject(AccountStore(iapService: IAPService()))
}