Remove ZIP code step from onboarding, use home profile instead

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.
This commit is contained in:
Trey T
2026-03-30 11:15:06 -05:00
parent 4609d5a953
commit 266d540d28
5 changed files with 41 additions and 97 deletions

View File

@@ -103,8 +103,7 @@ fun OnboardingScreen(
if (userIntent == OnboardingIntent.JOIN_EXISTING) { if (userIntent == OnboardingIntent.JOIN_EXISTING) {
viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE) viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE)
} else { } else {
viewModel.createResidence() viewModel.goToStep(OnboardingStep.HOME_PROFILE)
viewModel.goToStep(OnboardingStep.RESIDENCE_LOCATION)
} }
} else { } else {
viewModel.nextStep() viewModel.nextStep()
@@ -118,8 +117,7 @@ fun OnboardingScreen(
if (userIntent == OnboardingIntent.JOIN_EXISTING) { if (userIntent == OnboardingIntent.JOIN_EXISTING) {
viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE) viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE)
} else { } else {
viewModel.createResidence() viewModel.goToStep(OnboardingStep.HOME_PROFILE)
viewModel.goToStep(OnboardingStep.RESIDENCE_LOCATION)
} }
} }
) )
@@ -129,19 +127,21 @@ fun OnboardingScreen(
onJoined = { viewModel.completeOnboarding() } onJoined = { viewModel.completeOnboarding() }
) )
OnboardingStep.RESIDENCE_LOCATION -> OnboardingLocationContent( OnboardingStep.RESIDENCE_LOCATION -> {
viewModel = viewModel, // Location step removed — skip to home profile if we land here
onLocationDetected = { zip -> LaunchedEffect(Unit) { viewModel.goToStep(OnboardingStep.HOME_PROFILE) }
viewModel.loadRegionalTemplates(zip) }
viewModel.nextStep()
},
onSkip = { viewModel.nextStep() }
)
OnboardingStep.HOME_PROFILE -> OnboardingHomeProfileContent( OnboardingStep.HOME_PROFILE -> OnboardingHomeProfileContent(
viewModel = viewModel, viewModel = viewModel,
onContinue = { viewModel.nextStep() }, onContinue = {
onSkip = { viewModel.skipStep() } viewModel.createResidence()
viewModel.nextStep()
},
onSkip = {
viewModel.createResidence()
viewModel.skipStep()
}
) )
OnboardingStep.FIRST_TASK -> OnboardingFirstTaskContent( OnboardingStep.FIRST_TASK -> OnboardingFirstTaskContent(
@@ -179,7 +179,6 @@ private fun OnboardingNavigationBar(
val showSkipButton = when (currentStep) { val showSkipButton = when (currentStep) {
OnboardingStep.VALUE_PROPS, OnboardingStep.VALUE_PROPS,
OnboardingStep.JOIN_RESIDENCE, OnboardingStep.JOIN_RESIDENCE,
OnboardingStep.RESIDENCE_LOCATION,
OnboardingStep.HOME_PROFILE, OnboardingStep.HOME_PROFILE,
OnboardingStep.FIRST_TASK, OnboardingStep.FIRST_TASK,
OnboardingStep.SUBSCRIPTION_UPSELL -> true OnboardingStep.SUBSCRIPTION_UPSELL -> true

View File

@@ -201,14 +201,14 @@ class OnboardingViewModel : ViewModel() {
if (_userIntent.value == OnboardingIntent.JOIN_EXISTING) { if (_userIntent.value == OnboardingIntent.JOIN_EXISTING) {
OnboardingStep.JOIN_RESIDENCE OnboardingStep.JOIN_RESIDENCE
} else { } else {
OnboardingStep.RESIDENCE_LOCATION OnboardingStep.HOME_PROFILE
} }
} }
OnboardingStep.JOIN_RESIDENCE -> { OnboardingStep.JOIN_RESIDENCE -> {
completeOnboarding() completeOnboarding()
OnboardingStep.JOIN_RESIDENCE OnboardingStep.JOIN_RESIDENCE
} }
OnboardingStep.RESIDENCE_LOCATION -> OnboardingStep.HOME_PROFILE OnboardingStep.RESIDENCE_LOCATION -> OnboardingStep.HOME_PROFILE // Skip past if somehow reached
OnboardingStep.HOME_PROFILE -> OnboardingStep.FIRST_TASK OnboardingStep.HOME_PROFILE -> OnboardingStep.FIRST_TASK
OnboardingStep.FIRST_TASK -> { OnboardingStep.FIRST_TASK -> {
completeOnboarding() completeOnboarding()
@@ -253,7 +253,6 @@ class OnboardingViewModel : ViewModel() {
fun skipStep() { fun skipStep() {
when (_currentStep.value) { when (_currentStep.value) {
OnboardingStep.VALUE_PROPS, OnboardingStep.VALUE_PROPS,
OnboardingStep.RESIDENCE_LOCATION,
OnboardingStep.HOME_PROFILE -> nextStep() OnboardingStep.HOME_PROFILE -> nextStep()
OnboardingStep.JOIN_RESIDENCE, OnboardingStep.JOIN_RESIDENCE,
OnboardingStep.FIRST_TASK, OnboardingStep.FIRST_TASK,

View File

@@ -64,8 +64,8 @@ struct OnboardingCoordinator: View {
return return
} }
let postalCode = onboardingState.pendingPostalCode.isEmpty ? nil : onboardingState.pendingPostalCode let postalCode: String? = nil
print("🏠 ONBOARDING: Creating residence with name: \(onboardingState.pendingResidenceName), zip: \(postalCode ?? "none")") print("🏠 ONBOARDING: Creating residence with name: \(onboardingState.pendingResidenceName)")
isCreatingResidence = true isCreatingResidence = true
@@ -126,7 +126,7 @@ struct OnboardingCoordinator: View {
} }
/// Current step index for progress indicator (0-based) /// Current step index for progress indicator (0-based)
/// Flow: Welcome Features Name Account Verify Location Home Profile Tasks Upsell /// Flow: Welcome Features Name Account Verify Home Profile Tasks Upsell
private var currentProgressStep: Int { private var currentProgressStep: Int {
switch onboardingState.currentStep { switch onboardingState.currentStep {
case .welcome: return 0 case .welcome: return 0
@@ -155,7 +155,7 @@ struct OnboardingCoordinator: View {
/// Whether to show the skip button /// Whether to show the skip button
private var showSkipButton: Bool { private var showSkipButton: Bool {
switch onboardingState.currentStep { switch onboardingState.currentStep {
case .valueProps, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell: case .valueProps, .joinResidence, .homeProfile, .firstTask, .subscriptionUpsell:
return true return true
default: default:
return false return false
@@ -197,9 +197,6 @@ struct OnboardingCoordinator: View {
switch onboardingState.currentStep { switch onboardingState.currentStep {
case .valueProps: case .valueProps:
goForward() goForward()
case .residenceLocation:
// Skipping location go to home profile
goForward(to: .homeProfile)
case .homeProfile: case .homeProfile:
// Skipping home profile create residence without profile data, go to tasks // Skipping home profile create residence without profile data, go to tasks
createResidenceIfNeeded(thenNavigateTo: .firstTask) createResidenceIfNeeded(thenNavigateTo: .firstTask)
@@ -304,7 +301,7 @@ struct OnboardingCoordinator: View {
if onboardingState.userIntent == .joinExisting { if onboardingState.userIntent == .joinExisting {
goForward(to: .joinResidence) goForward(to: .joinResidence)
} else { } else {
goForward(to: .residenceLocation) goForward(to: .homeProfile)
} }
} else { } else {
goForward() goForward()
@@ -320,7 +317,7 @@ struct OnboardingCoordinator: View {
if onboardingState.userIntent == .joinExisting { if onboardingState.userIntent == .joinExisting {
goForward(to: .joinResidence) goForward(to: .joinResidence)
} else { } else {
goForward(to: .residenceLocation) goForward(to: .homeProfile)
} }
} }
) )
@@ -336,18 +333,10 @@ struct OnboardingCoordinator: View {
.transition(navigationTransition) .transition(navigationTransition)
case .residenceLocation: case .residenceLocation:
OnboardingLocationContent( // Location step removed skip to home profile if we land here
onLocationDetected: { zip in EmptyView()
// Load regional templates in background .onAppear { goForward(to: .homeProfile) }
onboardingState.loadRegionalTemplates(zip: zip) .transition(navigationTransition)
// Go to home profile step (residence created after profile)
goForward()
},
onSkip: {
// Handled by handleSkip() above
}
)
.transition(navigationTransition)
case .homeProfile: case .homeProfile:
OnboardingHomeProfileContent( OnboardingHomeProfileContent(

View File

@@ -53,12 +53,9 @@ struct OnboardingFirstTaskContent: View {
/// Cached categories computed once and stored to preserve stable UUIDs /// Cached categories computed once and stored to preserve stable UUIDs
@State private var taskCategoriesCache: [OnboardingTaskCategory]? = nil @State private var taskCategoriesCache: [OnboardingTaskCategory]? = nil
/// Uses API-driven regional templates when available, falls back to hardcoded defaults /// Task categories for the Browse tab
private var taskCategories: [OnboardingTaskCategory] { private var taskCategories: [OnboardingTaskCategory] {
if let cached = taskCategoriesCache { return cached } if let cached = taskCategoriesCache { return cached }
if !onboardingState.regionalTemplates.isEmpty {
return categoriesFromAPI(onboardingState.regionalTemplates)
}
return fallbackCategories return fallbackCategories
} }
@@ -297,9 +294,7 @@ struct OnboardingFirstTaskContent: View {
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.a11yHeader() .a11yHeader()
Text(onboardingState.regionalTemplates.isEmpty Text("Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!")
? "Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!"
: "Here are tasks recommended for your area.\nPick the ones you'd like to track!")
.font(.system(size: 15, weight: .medium)) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@@ -465,11 +460,7 @@ struct OnboardingFirstTaskContent: View {
isAnimating = true isAnimating = true
// Build and cache categories once to preserve stable UUIDs // Build and cache categories once to preserve stable UUIDs
if taskCategoriesCache == nil { if taskCategoriesCache == nil {
if !onboardingState.regionalTemplates.isEmpty { taskCategoriesCache = fallbackCategories
taskCategoriesCache = categoriesFromAPI(onboardingState.regionalTemplates)
} else {
taskCategoriesCache = fallbackCategories
}
} }
// Expand first category by default // Expand first category by default
if let first = taskCategories.first?.name { if let first = taskCategories.first?.name {
@@ -485,23 +476,15 @@ struct OnboardingFirstTaskContent: View {
private func selectPopularTasks() { private func selectPopularTasks() {
withAnimation(.spring(response: 0.3)) { withAnimation(.spring(response: 0.3)) {
if !onboardingState.regionalTemplates.isEmpty { let popularTaskTitles = [
// API templates: select the first tasks (they're ordered by display_order) "Change HVAC Filter",
for task in allTasks { "Test Smoke Detectors",
selectedTasks.insert(task.id) "Check for Leaks",
} "Clean Gutters",
} else { "Clean Refrigerator Coils"
// Fallback: select hardcoded popular tasks ]
let popularTaskTitles = [ for task in allTasks where popularTaskTitles.contains(task.title) {
"Change HVAC Filter", selectedTasks.insert(task.id)
"Test Smoke Detectors",
"Check for Leaks",
"Clean Gutters",
"Clean Refrigerator Coils"
]
for task in allTasks where popularTaskTitles.contains(task.title) {
selectedTasks.insert(task.id)
}
} }
} }
} }

View File

@@ -36,15 +36,6 @@ class OnboardingState: ObservableObject {
/// The ID of the residence created during onboarding (used for task creation) /// The ID of the residence created during onboarding (used for task creation)
@Published var createdResidenceId: Int32? = nil @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) // MARK: - Home Profile State (collected during onboarding)
@Published var pendingHeatingType: String? = nil @Published var pendingHeatingType: String? = nil
@@ -80,20 +71,6 @@ class OnboardingState: ObservableObject {
private init() {} 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 /// Start the onboarding flow
func startOnboarding() { func startOnboarding() {
isOnboardingActive = true isOnboardingActive = true
@@ -102,7 +79,7 @@ class OnboardingState: ObservableObject {
} }
/// Move to the next step in the flow /// Move to the next step in the flow
/// Order: Welcome Features Name Account Verify Location Tasks Upsell /// Order: Welcome Features Name Account Verify Home Profile Tasks Upsell
func nextStep() { func nextStep() {
switch currentStep { switch currentStep {
case .welcome: case .welcome:
@@ -121,11 +98,12 @@ class OnboardingState: ObservableObject {
if userIntent == .joinExisting { if userIntent == .joinExisting {
currentStep = .joinResidence currentStep = .joinResidence
} else { } else {
currentStep = .residenceLocation currentStep = .homeProfile
} }
case .joinResidence: case .joinResidence:
completeOnboarding() completeOnboarding()
case .residenceLocation: case .residenceLocation:
// Skip past this step if we somehow land here
currentStep = .homeProfile currentStep = .homeProfile
case .homeProfile: case .homeProfile:
currentStep = .firstTask currentStep = .firstTask
@@ -152,8 +130,6 @@ class OnboardingState: ObservableObject {
hasCompletedOnboarding = true hasCompletedOnboarding = true
isOnboardingActive = false isOnboardingActive = false
pendingResidenceName = "" pendingResidenceName = ""
pendingPostalCode = ""
regionalTemplates = []
createdResidenceId = nil createdResidenceId = nil
userIntent = .unknown userIntent = .unknown
resetHomeProfile() resetHomeProfile()
@@ -165,8 +141,6 @@ class OnboardingState: ObservableObject {
hasCompletedOnboarding = false hasCompletedOnboarding = false
isOnboardingActive = false isOnboardingActive = false
pendingResidenceName = "" pendingResidenceName = ""
pendingPostalCode = ""
regionalTemplates = []
createdResidenceId = nil createdResidenceId = nil
userIntent = .unknown userIntent = .unknown
currentStep = .welcome currentStep = .welcome