Files
honeyDueKMP/iosApp/iosApp/Onboarding/OnboardingState.swift
Trey T 4609d5a953 Smart onboarding: home profile, tabbed tasks, free app
New onboarding step: "Tell us about your home" with chip-based pickers
for systems (heating/cooling/water heater), features (pool, fireplace,
garage, etc.), exterior (roof, siding), interior (flooring, landscaping).
All optional, skippable.

Tabbed task selection: "For You" tab shows personalized suggestions
based on home profile, "Browse All" has existing category browser.
Removed 5-task limit — users can add unlimited tasks.

Removed subscription upsell from onboarding flow — app is free.
Fixed picker capsule squishing bug with .fixedSize() modifier.

Both iOS and Compose implementations updated.
2026-03-30 09:02:27 -05:00

233 lines
8.0 KiB
Swift

import SwiftUI
import ComposeApp
/// User's intent during onboarding
enum OnboardingIntent: String {
case unknown
case startFresh // Creating a new residence
case joinExisting // Joining with a share code
}
/// Manages the state of the onboarding flow
@MainActor
class OnboardingState: ObservableObject {
static let shared = OnboardingState()
// MARK: - Persisted State (via @AppStorage, survives app restarts)
/// Whether the user has completed onboarding.
/// This is the persisted flag read at launch to decide whether to show onboarding.
/// When set to `true`, Kotlin DataManager is also updated to keep both layers in sync.
@AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding: Bool = false {
didSet {
// Keep Kotlin DataManager in sync so Android/shared code sees the same value.
ComposeApp.DataManager.shared.setHasCompletedOnboarding(completed: hasCompletedOnboarding)
}
}
/// The name of the residence being created during onboarding
@AppStorage("onboardingResidenceName") var pendingResidenceName: String = ""
/// Backing storage for user intent (persisted across app restarts)
@AppStorage("onboardingUserIntent") private var userIntentRaw: String = OnboardingIntent.unknown.rawValue
// MARK: - Transient State (via @Published, reset each session as needed)
/// The ID of the residence created during onboarding (used for task creation)
@Published var createdResidenceId: Int32? = nil
/// ZIP code entered during the location step (used for residence creation and regional templates)
@Published var pendingPostalCode: String = ""
/// Regional task templates loaded from API based on ZIP code
@Published var regionalTemplates: [TaskTemplate] = []
/// Whether regional templates are currently loading
@Published var isLoadingTemplates: Bool = false
// MARK: - Home Profile State (collected during onboarding)
@Published var pendingHeatingType: String? = nil
@Published var pendingCoolingType: String? = nil
@Published var pendingWaterHeaterType: String? = nil
@Published var pendingRoofType: String? = nil
@Published var pendingHasPool: Bool = false
@Published var pendingHasSprinklerSystem: Bool = false
@Published var pendingHasSeptic: Bool = false
@Published var pendingHasFireplace: Bool = false
@Published var pendingHasGarage: Bool = false
@Published var pendingHasBasement: Bool = false
@Published var pendingHasAttic: Bool = false
@Published var pendingExteriorType: String? = nil
@Published var pendingFlooringPrimary: String? = nil
@Published var pendingLandscapingType: String? = nil
/// The user's selected intent (start fresh or join existing).
/// Reads/writes the persisted @AppStorage value and notifies SwiftUI of the change.
var userIntent: OnboardingIntent {
get { OnboardingIntent(rawValue: userIntentRaw) ?? .unknown }
set {
userIntentRaw = newValue.rawValue
objectWillChange.send()
}
}
/// Current step in the onboarding flow
@Published var currentStep: OnboardingStep = .welcome
/// Whether onboarding is currently active
@Published var isOnboardingActive: Bool = false
private init() {}
/// Load regional task templates from the backend for the given ZIP code
func loadRegionalTemplates(zip: String) {
pendingPostalCode = zip
isLoadingTemplates = true
Task {
defer { self.isLoadingTemplates = false }
let result = try await APILayer.shared.getRegionalTemplates(state: nil, zip: zip)
if let success = result as? ApiResultSuccess<NSArray>,
let templates = success.data as? [TaskTemplate] {
self.regionalTemplates = templates
}
}
}
/// Start the onboarding flow
func startOnboarding() {
isOnboardingActive = true
currentStep = .welcome
userIntent = .unknown
}
/// Move to the next step in the flow
/// Order: Welcome Features Name Account Verify Location Tasks Upsell
func nextStep() {
switch currentStep {
case .welcome:
if userIntent == .joinExisting {
currentStep = .createAccount
} else {
currentStep = .valueProps
}
case .valueProps:
currentStep = .nameResidence
case .nameResidence:
currentStep = .createAccount
case .createAccount:
currentStep = .verifyEmail
case .verifyEmail:
if userIntent == .joinExisting {
currentStep = .joinResidence
} else {
currentStep = .residenceLocation
}
case .joinResidence:
completeOnboarding()
case .residenceLocation:
currentStep = .homeProfile
case .homeProfile:
currentStep = .firstTask
case .firstTask:
completeOnboarding()
case .subscriptionUpsell:
completeOnboarding()
}
}
/// Skip to a specific step
func skipTo(_ step: OnboardingStep) {
currentStep = step
}
/// Complete the onboarding flow.
/// Setting `hasCompletedOnboarding` also syncs with Kotlin DataManager via `didSet`.
/// Guards against completing without authentication to prevent broken state.
func completeOnboarding() {
guard AuthenticationManager.shared.isAuthenticated else {
// Don't complete onboarding without auth
return
}
hasCompletedOnboarding = true
isOnboardingActive = false
pendingResidenceName = ""
pendingPostalCode = ""
regionalTemplates = []
createdResidenceId = nil
userIntent = .unknown
resetHomeProfile()
}
/// Reset onboarding state (useful for testing or re-onboarding).
/// Setting `hasCompletedOnboarding` also syncs with Kotlin DataManager via `didSet`.
func reset() {
hasCompletedOnboarding = false
isOnboardingActive = false
pendingResidenceName = ""
pendingPostalCode = ""
regionalTemplates = []
createdResidenceId = nil
userIntent = .unknown
currentStep = .welcome
resetHomeProfile()
}
/// Reset all home profile fields
private func resetHomeProfile() {
pendingHeatingType = nil
pendingCoolingType = nil
pendingWaterHeaterType = nil
pendingRoofType = nil
pendingHasPool = false
pendingHasSprinklerSystem = false
pendingHasSeptic = false
pendingHasFireplace = false
pendingHasGarage = false
pendingHasBasement = false
pendingHasAttic = false
pendingExteriorType = nil
pendingFlooringPrimary = nil
pendingLandscapingType = nil
}
}
/// Steps in the onboarding flow
enum OnboardingStep: Int, CaseIterable {
case welcome = 0
case nameResidence = 1
case valueProps = 2
case createAccount = 3
case verifyEmail = 4
case joinResidence = 5 // Only for users joining with a code
case residenceLocation = 6 // ZIP code entry for regional templates
case homeProfile = 7 // Home systems & features (optional)
case firstTask = 8
case subscriptionUpsell = 9
var title: String {
switch self {
case .welcome:
return "Welcome"
case .nameResidence:
return "Name Your Home"
case .valueProps:
return "Features"
case .createAccount:
return "Create Account"
case .verifyEmail:
return "Verify Email"
case .joinResidence:
return "Join Residence"
case .residenceLocation:
return "Your Location"
case .homeProfile:
return "Home Profile"
case .firstTask:
return "First Task"
case .subscriptionUpsell:
return "Go Pro"
}
}
}