initial version
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OnboardingView: View {
|
||||
@Binding var onboardingCompleted: Bool
|
||||
|
||||
@State private var currentPage = 0
|
||||
@State private var selectedCurrency = Locale.current.currency?.identifier ?? "EUR"
|
||||
@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.pie.fill",
|
||||
title: "Long-Term Tracking",
|
||||
description: "A calm, offline-first portfolio tracker for investors who update monthly.",
|
||||
color: .appPrimary
|
||||
),
|
||||
OnboardingPage(
|
||||
icon: "calendar.circle.fill",
|
||||
title: "Monthly Check-ins",
|
||||
description: "Build a deliberate habit. Update sources, log contributions, and add a short note.",
|
||||
color: .positiveGreen
|
||||
),
|
||||
OnboardingPage(
|
||||
icon: "bell.badge.fill",
|
||||
title: "Gentle Reminders",
|
||||
description: "Get a monthly nudge to review your portfolio without realtime noise.",
|
||||
color: .appWarning
|
||||
),
|
||||
OnboardingPage(
|
||||
icon: "leaf.fill",
|
||||
title: "Calm Mode",
|
||||
description: "Hide short-term swings and focus on contributions and long-term growth.",
|
||||
color: .appSecondary
|
||||
)
|
||||
]
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
.sheet(isPresented: $showingImportSheet) {
|
||||
ImportDataView()
|
||||
}
|
||||
.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
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Image("BrandMark")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 72, height: 72)
|
||||
|
||||
Text("Quick Start")
|
||||
.font(.title.weight(.bold))
|
||||
|
||||
Text("Pick your currency and start with sample data or import your own.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 30)
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
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("Load sample portfolio", isOn: $useSampleData)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Toggle("Enable Calm Mode (recommended)", isOn: $calmModeEnabled)
|
||||
.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)
|
||||
|
||||
if cloudSyncEnabled {
|
||||
Text("iCloud sync starts after you restart the app.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
onImport()
|
||||
} label: {
|
||||
Label("Import", systemImage: "square.and.arrow.down")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Button {
|
||||
onAddSource()
|
||||
} label: {
|
||||
Label("Add Source", systemImage: "plus")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appSecondary.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()))
|
||||
}
|
||||
Reference in New Issue
Block a user