266d540d28
ZIP code was US-only and redundant now that the suggestion engine uses home profile features (heating, pool, etc.) for personalization. Onboarding flow: Welcome → Value Props → Name → Account → Verify → Home Profile → Task Selection (was: ...Verify → ZIP → Home Profile...) Removed regionalTemplates references from task selection view. Both iOS and Compose flows updated.
403 lines
15 KiB
Swift
403 lines
15 KiB
Swift
import SwiftUI
|
|
import ComposeApp
|
|
|
|
/// Coordinates the onboarding flow, presenting the appropriate view based on current step
|
|
struct OnboardingCoordinator: View {
|
|
@ObservedObject private var onboardingState = OnboardingState.shared
|
|
@StateObject private var residenceViewModel = ResidenceViewModel()
|
|
@State private var isNavigatingBack = false
|
|
@State private var isCreatingResidence = false
|
|
|
|
var onComplete: () -> Void
|
|
|
|
/// Transition that respects navigation direction
|
|
private var navigationTransition: AnyTransition {
|
|
if isNavigatingBack {
|
|
// Going back: new view slides in from left, old view fades out in place
|
|
return .asymmetric(
|
|
insertion: .move(edge: .leading),
|
|
removal: .opacity
|
|
)
|
|
} else {
|
|
// Going forward: new view slides in from right, old view fades out in place
|
|
return .asymmetric(
|
|
insertion: .move(edge: .trailing),
|
|
removal: .opacity
|
|
)
|
|
}
|
|
}
|
|
|
|
private func goBack(to step: OnboardingStep) {
|
|
isNavigatingBack = true
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
onboardingState.currentStep = step
|
|
} completion: {
|
|
isNavigatingBack = false
|
|
}
|
|
}
|
|
|
|
private func goForward() {
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
isNavigatingBack = false
|
|
onboardingState.nextStep()
|
|
}
|
|
}
|
|
|
|
private func goForward(to step: OnboardingStep) {
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
isNavigatingBack = false
|
|
onboardingState.currentStep = step
|
|
}
|
|
}
|
|
|
|
/// Creates a residence with the pending name and postal code from onboarding, then calls completion
|
|
private func createResidenceIfNeeded(thenNavigateTo step: OnboardingStep) {
|
|
print("🏠 ONBOARDING: createResidenceIfNeeded called")
|
|
print("🏠 ONBOARDING: userIntent = \(onboardingState.userIntent)")
|
|
print("🏠 ONBOARDING: pendingResidenceName = '\(onboardingState.pendingResidenceName)'")
|
|
|
|
// Only create residence if user is starting fresh (not joining existing)
|
|
guard onboardingState.userIntent == .startFresh,
|
|
!onboardingState.pendingResidenceName.isEmpty else {
|
|
print("🏠 ONBOARDING: Skipping residence creation - conditions not met")
|
|
goForward(to: step)
|
|
return
|
|
}
|
|
|
|
let postalCode: String? = nil
|
|
print("🏠 ONBOARDING: Creating residence with name: \(onboardingState.pendingResidenceName)")
|
|
|
|
isCreatingResidence = true
|
|
|
|
// Collect home profile booleans — only send true values
|
|
let hasPool = onboardingState.pendingHasPool ? KotlinBoolean(bool: true) : nil
|
|
let hasSprinkler = onboardingState.pendingHasSprinklerSystem ? KotlinBoolean(bool: true) : nil
|
|
let hasSeptic = onboardingState.pendingHasSeptic ? KotlinBoolean(bool: true) : nil
|
|
let hasFireplace = onboardingState.pendingHasFireplace ? KotlinBoolean(bool: true) : nil
|
|
let hasGarage = onboardingState.pendingHasGarage ? KotlinBoolean(bool: true) : nil
|
|
let hasBasement = onboardingState.pendingHasBasement ? KotlinBoolean(bool: true) : nil
|
|
let hasAttic = onboardingState.pendingHasAttic ? KotlinBoolean(bool: true) : nil
|
|
|
|
let request = ResidenceCreateRequest(
|
|
name: onboardingState.pendingResidenceName,
|
|
propertyTypeId: nil,
|
|
streetAddress: nil,
|
|
apartmentUnit: nil,
|
|
city: nil,
|
|
stateProvince: nil,
|
|
postalCode: postalCode,
|
|
country: nil,
|
|
bedrooms: nil,
|
|
bathrooms: nil,
|
|
squareFootage: nil,
|
|
lotSize: nil,
|
|
yearBuilt: nil,
|
|
description: nil,
|
|
purchaseDate: nil,
|
|
purchasePrice: nil,
|
|
isPrimary: KotlinBoolean(bool: true),
|
|
heatingType: onboardingState.pendingHeatingType,
|
|
coolingType: onboardingState.pendingCoolingType,
|
|
waterHeaterType: onboardingState.pendingWaterHeaterType,
|
|
roofType: onboardingState.pendingRoofType,
|
|
hasPool: hasPool,
|
|
hasSprinklerSystem: hasSprinkler,
|
|
hasSeptic: hasSeptic,
|
|
hasFireplace: hasFireplace,
|
|
hasGarage: hasGarage,
|
|
hasBasement: hasBasement,
|
|
hasAttic: hasAttic,
|
|
exteriorType: onboardingState.pendingExteriorType,
|
|
flooringPrimary: onboardingState.pendingFlooringPrimary,
|
|
landscapingType: onboardingState.pendingLandscapingType
|
|
)
|
|
|
|
residenceViewModel.createResidence(request: request) { (residence: ResidenceResponse?) in
|
|
self.isCreatingResidence = false
|
|
if let residence = residence {
|
|
print("🏠 ONBOARDING: Residence created successfully with ID: \(residence.id)")
|
|
self.onboardingState.createdResidenceId = residence.id
|
|
} else {
|
|
print("🏠 ONBOARDING: Residence creation FAILED")
|
|
}
|
|
// Navigate regardless of success - user can create residence later if needed
|
|
self.goForward(to: step)
|
|
}
|
|
}
|
|
|
|
/// Current step index for progress indicator (0-based)
|
|
/// Flow: Welcome → Features → Name → Account → Verify → Home Profile → Tasks → Upsell
|
|
private var currentProgressStep: Int {
|
|
switch onboardingState.currentStep {
|
|
case .welcome: return 0
|
|
case .valueProps: return 1
|
|
case .nameResidence: return 2
|
|
case .createAccount: return 3
|
|
case .verifyEmail: return 4
|
|
case .joinResidence: return 4
|
|
case .residenceLocation: return 4
|
|
case .homeProfile: return 4
|
|
case .firstTask: return 4
|
|
case .subscriptionUpsell: return 4
|
|
}
|
|
}
|
|
|
|
/// Whether to show the back button
|
|
private var showBackButton: Bool {
|
|
switch onboardingState.currentStep {
|
|
case .welcome, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
/// Whether to show the skip button
|
|
private var showSkipButton: Bool {
|
|
switch onboardingState.currentStep {
|
|
case .valueProps, .joinResidence, .homeProfile, .firstTask, .subscriptionUpsell:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Whether to show the progress indicator
|
|
private var showProgressIndicator: Bool {
|
|
switch onboardingState.currentStep {
|
|
case .welcome, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
private func handleBack() {
|
|
// Flow: Welcome → Features → Name Residence → Create Account → Verify → Tasks → Upsell
|
|
switch onboardingState.currentStep {
|
|
case .valueProps:
|
|
goBack(to: .welcome)
|
|
case .nameResidence:
|
|
goBack(to: .valueProps)
|
|
case .createAccount:
|
|
if onboardingState.userIntent == .joinExisting {
|
|
goBack(to: .welcome)
|
|
} else {
|
|
goBack(to: .nameResidence)
|
|
}
|
|
case .verifyEmail:
|
|
AuthenticationManager.shared.logout()
|
|
goBack(to: .createAccount)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func handleSkip() {
|
|
switch onboardingState.currentStep {
|
|
case .valueProps:
|
|
goForward()
|
|
case .homeProfile:
|
|
// Skipping home profile — create residence without profile data, go to tasks
|
|
createResidenceIfNeeded(thenNavigateTo: .firstTask)
|
|
case .joinResidence:
|
|
onboardingState.completeOnboarding()
|
|
onComplete()
|
|
case .firstTask, .subscriptionUpsell:
|
|
onboardingState.completeOnboarding()
|
|
onComplete()
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Shared navigation bar - stays static
|
|
HStack {
|
|
// Back button — fixed width so progress dots stay centered
|
|
Button(action: handleBack) {
|
|
Image(systemName: "chevron.left")
|
|
.font(.title2)
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.backButton)
|
|
.accessibilityLabel("Go back")
|
|
.frame(width: 44, alignment: .leading)
|
|
.opacity(showBackButton ? 1 : 0)
|
|
.disabled(!showBackButton)
|
|
|
|
Spacer()
|
|
|
|
// Progress indicator
|
|
if showProgressIndicator {
|
|
OnboardingProgressIndicator(currentStep: currentProgressStep, totalSteps: 5)
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.progressIndicator)
|
|
.accessibilityLabel("Step \(currentProgressStep + 1) of 5")
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Skip button — fixed width to match back button
|
|
Button(action: handleSkip) {
|
|
Text("Skip")
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.skipButton)
|
|
.accessibilityLabel("Skip")
|
|
.frame(width: 44, alignment: .trailing)
|
|
.opacity(showSkipButton ? 1 : 0)
|
|
.disabled(!showSkipButton)
|
|
}
|
|
.padding(.horizontal, AppSpacing.lg)
|
|
.padding(.vertical, AppSpacing.md)
|
|
|
|
// Content area - this is what transitions
|
|
ZStack {
|
|
switch onboardingState.currentStep {
|
|
case .welcome:
|
|
OnboardingWelcomeContent(
|
|
onStartFresh: {
|
|
onboardingState.userIntent = .startFresh
|
|
goForward()
|
|
},
|
|
onJoinExisting: {
|
|
onboardingState.userIntent = .joinExisting
|
|
goForward()
|
|
},
|
|
onLogin: {
|
|
// Returning user logged in - mark onboarding complete and go to main view
|
|
onboardingState.completeOnboarding()
|
|
AuthenticationManager.shared.markVerified()
|
|
onComplete()
|
|
}
|
|
)
|
|
.transition(navigationTransition)
|
|
|
|
case .nameResidence:
|
|
OnboardingNameResidenceContent(
|
|
residenceName: $onboardingState.pendingResidenceName,
|
|
onContinue: {
|
|
goForward()
|
|
}
|
|
)
|
|
.transition(navigationTransition)
|
|
|
|
case .valueProps:
|
|
OnboardingValuePropsContent(
|
|
onContinue: {
|
|
goForward()
|
|
}
|
|
)
|
|
.transition(navigationTransition)
|
|
|
|
case .createAccount:
|
|
OnboardingCreateAccountContent(
|
|
onAccountCreated: { isVerified in
|
|
if isVerified {
|
|
// Skip email verification
|
|
if onboardingState.userIntent == .joinExisting {
|
|
goForward(to: .joinResidence)
|
|
} else {
|
|
goForward(to: .homeProfile)
|
|
}
|
|
} else {
|
|
goForward()
|
|
}
|
|
}
|
|
)
|
|
.transition(navigationTransition)
|
|
|
|
case .verifyEmail:
|
|
OnboardingVerifyEmailContent(
|
|
onVerified: {
|
|
print("🏠 ONBOARDING: onVerified callback triggered in coordinator")
|
|
if onboardingState.userIntent == .joinExisting {
|
|
goForward(to: .joinResidence)
|
|
} else {
|
|
goForward(to: .homeProfile)
|
|
}
|
|
}
|
|
)
|
|
.transition(navigationTransition)
|
|
|
|
case .joinResidence:
|
|
OnboardingJoinResidenceContent(
|
|
onJoined: {
|
|
onboardingState.completeOnboarding()
|
|
onComplete()
|
|
}
|
|
)
|
|
.transition(navigationTransition)
|
|
|
|
case .residenceLocation:
|
|
// Location step removed — skip to home profile if we land here
|
|
EmptyView()
|
|
.onAppear { goForward(to: .homeProfile) }
|
|
.transition(navigationTransition)
|
|
|
|
case .homeProfile:
|
|
OnboardingHomeProfileContent(
|
|
onContinue: {
|
|
// Create residence with all collected data, then go to tasks
|
|
createResidenceIfNeeded(thenNavigateTo: .firstTask)
|
|
},
|
|
onSkip: {
|
|
// Handled by handleSkip() above
|
|
}
|
|
)
|
|
.transition(navigationTransition)
|
|
|
|
case .firstTask:
|
|
OnboardingFirstTaskContent(
|
|
residenceName: onboardingState.pendingResidenceName,
|
|
onTaskAdded: {
|
|
onboardingState.completeOnboarding()
|
|
onComplete()
|
|
}
|
|
)
|
|
.transition(navigationTransition)
|
|
|
|
case .subscriptionUpsell:
|
|
// Subscription removed from onboarding — app is free
|
|
// Immediately complete if we somehow land here
|
|
EmptyView()
|
|
.onAppear {
|
|
onboardingState.completeOnboarding()
|
|
onComplete()
|
|
}
|
|
.transition(navigationTransition)
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.3), value: onboardingState.currentStep)
|
|
}
|
|
.background(Color.appBackgroundPrimary)
|
|
}
|
|
}
|
|
|
|
// MARK: - Progress Indicator
|
|
|
|
struct OnboardingProgressIndicator: View {
|
|
let currentStep: Int
|
|
let totalSteps: Int
|
|
|
|
var body: some View {
|
|
HStack(spacing: AppSpacing.xs) {
|
|
ForEach(0..<totalSteps, id: \.self) { index in
|
|
Circle()
|
|
.fill(index <= currentStep ? Color.appPrimary : Color.appTextSecondary.opacity(0.3))
|
|
.frame(width: 8, height: 8)
|
|
.animation(.easeInOut(duration: 0.2), value: currentStep)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
OnboardingCoordinator(onComplete: {})
|
|
}
|