db844d5e80
Charts iPad (rediseño): - Fila de KPIs sobre el chart: Total Value, Period Return, CAGR, Volatility y Max Drawdown del rango activo (nuevo ChartsViewModel.portfolioMetrics, calculado sobre los TOTALES MENSUALES AGREGADOS — pasar snapshots multi-source crudos a calculateMetrics producía KPIs absurdos) - Sidebar (248pt) con tiles de chart type en grid 2 col agrupados por sección: Overview / Analyze / Risk / Forecast, con candado premium y accesibilidad - Selector de periodo como Picker segmentado nativo en la cabecera del chart (fuera del sidebar); título + descripción del chart visibles - Eliminado sheet de paywall duplicado del layout iPad Zoom (todas las series temporales): - Nuevo ChartZoom.swift: pinch + botones +/- y reset (HIG: el gesto nunca es el único camino), chartScrollableAxes + chartXVisibleDomain al hacer zoom - Integrado en Evolution, Contributions, Rolling 12M, Cashflow, Drawdown y Volatility Focus Mode eliminado (todo siempre disponible): - Fuera el toggle de Dashboard/Charts/Settings/Onboarding y los 6 @AppStorage - Todos los chart types siempre visibles; variantes completas en SourceDetail/SourceList - Home: Total Portfolio Value muestra SIEMPRE el cambio desde el último check-in - PeriodReturnsCard siempre visible Rendimiento y bugs (de la auditoría): - El cache de snapshots ya no se invalida al cambiar solo de chart type/rango/breakdown - calculateAnnualizedGrowth y el forecast a 12 meses ahora componen (la aproximación lineal sobreestimaba con historiales cortos) - Drawdown sin force unwrap; simulador: rama muerta reemplazada por preservación real de las asignaciones simuladas del usuario - UITest de captura de Charts con manejo del alert de notificaciones 12 strings nuevas localizadas en los 7 idiomas. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
365 lines
12 KiB
Swift
365 lines
12 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("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,
|
|
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 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)
|
|
|
|
|
|
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()))
|
|
}
|