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.
233 lines
8.0 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|