Add regional task templates to onboarding with multiple bug fixes

- Fetch regional templates by ZIP during onboarding and display categorized
  task suggestions (iOS + KMM shared layer)
- Fix multi-expand for task categories (toggle independently, not exclusive)
- Fix ScrollViewReader auto-scroll to expanded category sections
- Fix UUID stability bug: cache task categories to prevent ID regeneration
  that caused silent task creation failures
- Fix stale data after onboarding: force refresh residences and tasks in
  RootView onComplete callback
- Fix address formatting: show just ZIP code when city/state are empty
  instead of showing ", 75028" with leading comma

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-05 15:15:47 -06:00
parent 98dbacdea0
commit 48081c0cc8
15 changed files with 706 additions and 61 deletions

View File

@@ -46,18 +46,6 @@
"comment" : "A message displayed when a contractor is successfully imported to the user's contacts. The placeholder is replaced with the name of the imported contractor.",
"isCommentAutoGenerated" : true
},
"%@, %@ %@" : {
"comment" : "A label displaying the city, state, and postal code of the residence.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@, %2$@ %3$@"
}
}
}
},
"%@: %@" : {
"comment" : "An error message displayed when there was an issue loading tasks for a residence.",
"isCommentAutoGenerated" : true,
@@ -146,6 +134,9 @@
}
}
}
},
"12345" : {
},
"ABC123" : {
@@ -5264,6 +5255,9 @@
},
"CONFIRM PASSWORD" : {
},
"Continue" : {
},
"Continue with Free" : {
@@ -16992,6 +16986,9 @@
"Enter your email address and we'll send you a verification code" : {
"comment" : "A description below the email input field, instructing the user to enter their email address to receive a password reset code.",
"isCommentAutoGenerated" : true
},
"Enter your ZIP code so we can suggest\nmaintenance tasks for your climate region." : {
},
"Error" : {
"comment" : "The title of an alert that appears when there's an error.",
@@ -17358,6 +17355,9 @@
},
"Help improve Casera by sharing anonymous usage data" : {
},
"Here are tasks recommended for your area.\nPick the ones you'd like to track!" : {
},
"Hour" : {
"comment" : "A picker for selecting an hour.",
@@ -30125,6 +30125,9 @@
"Welcome to Your Space" : {
"comment" : "A welcoming message displayed at the top of the \"Organic Empty Residences\" view.",
"isCommentAutoGenerated" : true
},
"Where's your home?" : {
},
"You now have access to %@." : {
"comment" : "A message displayed when a user successfully imports a residence, indicating that they now have access to it. The argument is the name of the residence that was imported.",

View File

@@ -52,7 +52,7 @@ struct OnboardingCoordinator: View {
}
}
/// Creates a residence with the pending name from onboarding, then calls completion
/// 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)")
@@ -66,7 +66,8 @@ struct OnboardingCoordinator: View {
return
}
print("🏠 ONBOARDING: Creating residence with name: \(onboardingState.pendingResidenceName)")
let postalCode = onboardingState.pendingPostalCode.isEmpty ? nil : onboardingState.pendingPostalCode
print("🏠 ONBOARDING: Creating residence with name: \(onboardingState.pendingResidenceName), zip: \(postalCode ?? "none")")
isCreatingResidence = true
@@ -77,7 +78,7 @@ struct OnboardingCoordinator: View {
apartmentUnit: nil,
city: nil,
stateProvince: nil,
postalCode: nil,
postalCode: postalCode,
country: nil,
bedrooms: nil,
bathrooms: nil,
@@ -104,7 +105,7 @@ struct OnboardingCoordinator: View {
}
/// Current step index for progress indicator (0-based)
/// Flow: Welcome Features Name Residence Create Account Verify Tasks Upsell
/// Flow: Welcome Features Name Account Verify Location Tasks Upsell
private var currentProgressStep: Int {
switch onboardingState.currentStep {
case .welcome: return 0
@@ -113,6 +114,7 @@ struct OnboardingCoordinator: View {
case .createAccount: return 3
case .verifyEmail: return 4
case .joinResidence: return 4
case .residenceLocation: return 4
case .firstTask: return 4
case .subscriptionUpsell: return 4
}
@@ -121,7 +123,7 @@ struct OnboardingCoordinator: View {
/// Whether to show the back button
private var showBackButton: Bool {
switch onboardingState.currentStep {
case .welcome, .joinResidence, .firstTask, .subscriptionUpsell:
case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
return false
default:
return true
@@ -131,7 +133,7 @@ struct OnboardingCoordinator: View {
/// Whether to show the skip button
private var showSkipButton: Bool {
switch onboardingState.currentStep {
case .valueProps, .joinResidence, .firstTask, .subscriptionUpsell:
case .valueProps, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
return true
default:
return false
@@ -141,7 +143,7 @@ struct OnboardingCoordinator: View {
/// Whether to show the progress indicator
private var showProgressIndicator: Bool {
switch onboardingState.currentStep {
case .welcome, .joinResidence, .firstTask, .subscriptionUpsell:
case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
return false
default:
return true
@@ -173,6 +175,9 @@ struct OnboardingCoordinator: View {
switch onboardingState.currentStep {
case .valueProps:
goForward()
case .residenceLocation:
// Skipping location still need to create residence (without postal code)
createResidenceIfNeeded(thenNavigateTo: .firstTask)
case .joinResidence, .firstTask:
goForward()
case .subscriptionUpsell:
@@ -187,13 +192,14 @@ struct OnboardingCoordinator: View {
VStack(spacing: 0) {
// Shared navigation bar - stays static
HStack {
// Back button
// 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)
.frame(width: 44, alignment: .leading)
.opacity(showBackButton ? 1 : 0)
.disabled(!showBackButton)
@@ -207,7 +213,7 @@ struct OnboardingCoordinator: View {
Spacer()
// Skip button
// Skip button fixed width to match back button
Button(action: handleSkip) {
Text("Skip")
.font(.subheadline)
@@ -215,6 +221,7 @@ struct OnboardingCoordinator: View {
.foregroundColor(Color.appTextSecondary)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.skipButton)
.frame(width: 44, alignment: .trailing)
.opacity(showSkipButton ? 1 : 0)
.disabled(!showSkipButton)
}
@@ -268,7 +275,7 @@ struct OnboardingCoordinator: View {
if onboardingState.userIntent == .joinExisting {
goForward(to: .joinResidence)
} else {
createResidenceIfNeeded(thenNavigateTo: .firstTask)
goForward(to: .residenceLocation)
}
} else {
goForward()
@@ -281,13 +288,10 @@ struct OnboardingCoordinator: View {
OnboardingVerifyEmailContent(
onVerified: {
print("🏠 ONBOARDING: onVerified callback triggered in coordinator")
// NOTE: Do NOT call markVerified() here - it would cause RootView
// to switch to MainTabView before onboarding completes.
// markVerified() is called at the end of onboarding in onComplete.
if onboardingState.userIntent == .joinExisting {
goForward(to: .joinResidence)
} else {
createResidenceIfNeeded(thenNavigateTo: .firstTask)
goForward(to: .residenceLocation)
}
}
)
@@ -301,6 +305,20 @@ struct OnboardingCoordinator: View {
)
.transition(navigationTransition)
case .residenceLocation:
OnboardingLocationContent(
onLocationDetected: { zip in
// Load regional templates in background while creating residence
onboardingState.loadRegionalTemplates(zip: zip)
// Create residence with postal code, then go to first task
createResidenceIfNeeded(thenNavigateTo: .firstTask)
},
onSkip: {
// Handled by handleSkip() above
}
)
.transition(navigationTransition)
case .firstTask:
OnboardingFirstTaskContent(
residenceName: onboardingState.pendingResidenceName,

View File

@@ -11,14 +11,91 @@ struct OnboardingFirstTaskContent: View {
@ObservedObject private var onboardingState = OnboardingState.shared
@State private var selectedTasks: Set<UUID> = []
@State private var isCreatingTasks = false
@State private var expandedCategory: String? = nil
@State private var expandedCategories: Set<String> = []
@State private var isAnimating = false
@Environment(\.colorScheme) var colorScheme
/// Maximum tasks allowed for free tier (matches API TierLimits)
private let maxTasksAllowed = 5
private let taskCategories: [OnboardingTaskCategory] = [
/// Category colors by name (used for both API and fallback templates)
private static let categoryColors: [String: Color] = [
"hvac": .appPrimary,
"safety": .appError,
"plumbing": .appSecondary,
"landscaping": Color(hex: "#34C759") ?? .green,
"exterior": Color(hex: "#34C759") ?? .green,
"appliances": .appAccent,
"interior": Color(hex: "#AF52DE") ?? .purple,
"electrical": .appAccent,
]
/// Category icons by name
private static let categoryIcons: [String: String] = [
"hvac": "thermometer.medium",
"safety": "shield.checkered",
"plumbing": "drop.fill",
"landscaping": "leaf.fill",
"exterior": "leaf.fill",
"appliances": "refrigerator.fill",
"interior": "house.fill",
"electrical": "bolt.fill",
]
/// Cached categories computed once and stored to preserve stable UUIDs
@State private var taskCategoriesCache: [OnboardingTaskCategory]? = nil
/// Uses API-driven regional templates when available, falls back to hardcoded defaults
private var taskCategories: [OnboardingTaskCategory] {
if let cached = taskCategoriesCache { return cached }
if !onboardingState.regionalTemplates.isEmpty {
return categoriesFromAPI(onboardingState.regionalTemplates)
}
return fallbackCategories
}
/// Convert API TaskTemplate list into OnboardingTaskCategory groups
private func categoriesFromAPI(_ templates: [TaskTemplate]) -> [OnboardingTaskCategory] {
// Group by category name
var grouped: [String: [OnboardingTaskTemplate]] = [:]
var order: [String] = []
for template in templates {
let catName = template.categoryName
let catKey = catName.lowercased()
let color = Self.categoryColors[catKey] ?? .appPrimary
let icon = template.iconIos.isEmpty ? "wrench.fill" : template.iconIos
let freq = template.frequency?.displayName ?? "One time"
let task = OnboardingTaskTemplate(
icon: icon,
title: template.title,
category: catName.lowercased(),
frequency: freq.lowercased(),
color: color
)
if grouped[catName] == nil {
grouped[catName] = []
order.append(catName)
}
grouped[catName]?.append(task)
}
return order.compactMap { name in
guard let tasks = grouped[name] else { return nil }
let catKey = name.lowercased()
return OnboardingTaskCategory(
name: name,
icon: Self.categoryIcons[catKey] ?? "wrench.fill",
color: Self.categoryColors[catKey] ?? .appPrimary,
tasks: tasks
)
}
}
/// Hardcoded fallback categories when no API templates are available
private let fallbackCategories: [OnboardingTaskCategory] = [
OnboardingTaskCategory(
name: "HVAC & Climate",
icon: "thermometer.medium",
@@ -141,6 +218,7 @@ struct OnboardingFirstTaskContent: View {
}
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) {
// Header with celebration
@@ -208,7 +286,9 @@ struct OnboardingFirstTaskContent: View {
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text("Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!")
Text(onboardingState.regionalTemplates.isEmpty
? "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))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
@@ -237,18 +317,27 @@ struct OnboardingFirstTaskContent: View {
OrganicTaskCategorySection(
category: category,
selectedTasks: $selectedTasks,
isExpanded: expandedCategory == category.name,
isExpanded: expandedCategories.contains(category.name),
isAtMaxSelection: isAtMaxSelection,
onToggleExpand: {
let isExpanding = !expandedCategories.contains(category.name)
withAnimation(.spring(response: 0.3)) {
if expandedCategory == category.name {
expandedCategory = nil
if expandedCategories.contains(category.name) {
expandedCategories.remove(category.name)
} else {
expandedCategory = category.name
expandedCategories.insert(category.name)
}
}
if isExpanding {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
withAnimation {
proxy.scrollTo(category.name, anchor: .top)
}
}
}
}
)
.id(category.name)
}
}
.padding(.horizontal, OrganicSpacing.comfortable)
@@ -295,6 +384,7 @@ struct OnboardingFirstTaskContent: View {
}
.padding(.bottom, 140) // Space for button
}
} // ScrollViewReader
// Bottom action area
VStack(spacing: 14) {
@@ -341,8 +431,18 @@ struct OnboardingFirstTaskContent: View {
}
.onAppear {
isAnimating = true
// Build and cache categories once to preserve stable UUIDs
if taskCategoriesCache == nil {
if !onboardingState.regionalTemplates.isEmpty {
taskCategoriesCache = categoriesFromAPI(onboardingState.regionalTemplates)
} else {
taskCategoriesCache = fallbackCategories
}
}
// Expand first category by default
expandedCategory = taskCategories.first?.name
if let first = taskCategories.first?.name {
expandedCategories.insert(first)
}
}
.onDisappear {
isAnimating = false
@@ -350,19 +450,27 @@ struct OnboardingFirstTaskContent: View {
}
private func selectPopularTasks() {
// Select top popular tasks (up to max allowed)
let popularTaskTitles = [
"Change HVAC Filter",
"Test Smoke Detectors",
"Check for Leaks",
"Clean Gutters",
"Clean Refrigerator Coils"
]
withAnimation(.spring(response: 0.3)) {
for task in allTasks where popularTaskTitles.contains(task.title) {
if selectedTasks.count < maxTasksAllowed {
selectedTasks.insert(task.id)
if !onboardingState.regionalTemplates.isEmpty {
// API templates: select the first N tasks (they're ordered by display_order)
for task in allTasks {
if selectedTasks.count < maxTasksAllowed {
selectedTasks.insert(task.id)
}
}
} else {
// Fallback: select hardcoded popular tasks
let popularTaskTitles = [
"Change HVAC Filter",
"Test Smoke Detectors",
"Check for Leaks",
"Clean Gutters",
"Clean Refrigerator Coils"
]
for task in allTasks where popularTaskTitles.contains(task.title) {
if selectedTasks.count < maxTasksAllowed {
selectedTasks.insert(task.id)
}
}
}
}
@@ -388,6 +496,13 @@ struct OnboardingFirstTaskContent: View {
var completedCount = 0
let totalCount = selectedTemplates.count
// Safety: if no templates matched (shouldn't happen), skip
if totalCount == 0 {
isCreatingTasks = false
onTaskAdded()
return
}
// Format today's date as YYYY-MM-DD for the API
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"

View File

@@ -0,0 +1,251 @@
import SwiftUI
/// Screen: ZIP code entry for regional task templates - Content only (no navigation bar)
struct OnboardingLocationContent: View {
var onLocationDetected: (String) -> Void
var onSkip: () -> Void
@State private var zipCode: String = ""
@State private var isAnimating = false
@FocusState private var isTextFieldFocused: Bool
@Environment(\.colorScheme) var colorScheme
private var isValid: Bool {
zipCode.count == 5 && zipCode.allSatisfy(\.isNumber)
}
var body: some View {
ZStack {
WarmGradientBackground()
// Decorative blobs
GeometryReader { geo in
OrganicBlobShape(variation: 1)
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.06),
Color.appPrimary.opacity(0.01),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.3
)
)
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.25)
.offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.1)
.blur(radius: 20)
OrganicBlobShape(variation: 2)
.fill(
RadialGradient(
colors: [
Color.appAccent.opacity(0.05),
Color.appAccent.opacity(0.01),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.25
)
)
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.2)
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.75)
.blur(radius: 15)
}
VStack(spacing: 0) {
Spacer()
// Content
VStack(spacing: OrganicSpacing.comfortable) {
// Animated location icon
ZStack {
Circle()
.fill(
RadialGradient(
colors: [Color.appPrimary.opacity(0.15), Color.clear],
center: .center,
startRadius: 30,
endRadius: 80
)
)
.frame(width: 160, height: 160)
.offset(x: -20, y: -20)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
isAnimating
? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true)
: .default,
value: isAnimating
)
Circle()
.fill(
RadialGradient(
colors: [Color.appAccent.opacity(0.15), Color.clear],
center: .center,
startRadius: 30,
endRadius: 80
)
)
.frame(width: 160, height: 160)
.offset(x: 20, y: 20)
.scaleEffect(isAnimating ? 0.95 : 1.05)
.animation(
isAnimating
? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5)
: .default,
value: isAnimating
)
// Main icon
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appPrimary, Color.appSecondary],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 90, height: 90)
Image(systemName: "location.fill")
.font(.system(size: 40))
.foregroundColor(.white)
}
.naturalShadow(.pronounced)
}
// Title
VStack(spacing: 12) {
Text("Where's your home?")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
Text("Enter your ZIP code so we can suggest\nmaintenance tasks for your climate region.")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
}
// ZIP code input
VStack(alignment: .center, spacing: 12) {
HStack(spacing: 14) {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appPrimary.opacity(0.15), Color.appAccent.opacity(0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 40, height: 40)
Image(systemName: "mappin.circle.fill")
.font(.system(size: 20))
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary, Color.appAccent],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
TextField("12345", text: $zipCode)
.font(.system(size: 24, weight: .semibold))
.keyboardType(.numberPad)
.focused($isTextFieldFocused)
.multilineTextAlignment(.center)
.onChange(of: zipCode) { _, newValue in
// Only allow digits, max 5
let filtered = String(newValue.filter(\.isNumber).prefix(5))
if filtered != newValue {
zipCode = filtered
}
}
if !zipCode.isEmpty {
Button(action: { zipCode = "" }) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(Color.appTextSecondary.opacity(0.5))
}
}
}
.padding(18)
.frame(maxWidth: 240)
.background(
ZStack {
Color.appBackgroundSecondary
GrainTexture(opacity: 0.01)
}
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(
isTextFieldFocused
? LinearGradient(colors: [Color.appPrimary, Color.appAccent], startPoint: .leading, endPoint: .trailing)
: LinearGradient(colors: [Color.appTextSecondary.opacity(0.2), Color.appTextSecondary.opacity(0.2)], startPoint: .leading, endPoint: .trailing),
lineWidth: 2
)
)
.naturalShadow(isTextFieldFocused ? .medium : .subtle)
.animation(.easeInOut(duration: 0.2), value: isTextFieldFocused)
}
.padding(.horizontal, OrganicSpacing.comfortable)
}
Spacer()
// Continue button
Button(action: { onLocationDetected(zipCode) }) {
HStack(spacing: 10) {
Text("Continue")
.font(.system(size: 17, weight: .bold))
Image(systemName: "arrow.right")
.font(.system(size: 16, weight: .bold))
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
isValid
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing))
: AnyShapeStyle(Color.appTextSecondary.opacity(0.4))
)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.naturalShadow(isValid ? .medium : .subtle)
}
.disabled(!isValid)
.padding(.horizontal, OrganicSpacing.comfortable)
.padding(.bottom, OrganicSpacing.airy)
.animation(.easeInOut(duration: 0.2), value: isValid)
}
}
.onAppear {
isAnimating = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isTextFieldFocused = true
}
}
.onDisappear {
isAnimating = false
}
}
}
#Preview {
OnboardingLocationContent(
onLocationDetected: { _ in },
onSkip: {}
)
}

View File

@@ -36,6 +36,15 @@ class OnboardingState: ObservableObject {
/// 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
/// 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 {
@@ -54,6 +63,20 @@ class OnboardingState: ObservableObject {
private init() {}
/// Load regional task templates from the backend for the given ZIP code
func loadRegionalTemplates(zip: String) {
pendingPostalCode = zip
isLoadingTemplates = true
Task {
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
}
self.isLoadingTemplates = false
}
}
/// Start the onboarding flow
func startOnboarding() {
isOnboardingActive = true
@@ -62,17 +85,17 @@ class OnboardingState: ObservableObject {
}
/// Move to the next step in the flow
/// New order: Welcome Features Name Residence Create Account Verify Tasks Upsell
/// Order: Welcome Features Name Account Verify Location Tasks Upsell
func nextStep() {
switch currentStep {
case .welcome:
if userIntent == .joinExisting {
currentStep = .createAccount
} else {
currentStep = .valueProps // Features first to wow the user
currentStep = .valueProps
}
case .valueProps:
currentStep = .nameResidence // Then name the house
currentStep = .nameResidence
case .nameResidence:
currentStep = .createAccount
case .createAccount:
@@ -81,10 +104,12 @@ class OnboardingState: ObservableObject {
if userIntent == .joinExisting {
currentStep = .joinResidence
} else {
currentStep = .firstTask
currentStep = .residenceLocation
}
case .joinResidence:
currentStep = .subscriptionUpsell
case .residenceLocation:
currentStep = .firstTask
case .firstTask:
currentStep = .subscriptionUpsell
case .subscriptionUpsell:
@@ -103,6 +128,8 @@ class OnboardingState: ObservableObject {
hasCompletedOnboarding = true
isOnboardingActive = false
pendingResidenceName = ""
pendingPostalCode = ""
regionalTemplates = []
createdResidenceId = nil
userIntent = .unknown
}
@@ -113,6 +140,8 @@ class OnboardingState: ObservableObject {
hasCompletedOnboarding = false
isOnboardingActive = false
pendingResidenceName = ""
pendingPostalCode = ""
regionalTemplates = []
createdResidenceId = nil
userIntent = .unknown
currentStep = .welcome
@@ -126,9 +155,10 @@ enum OnboardingStep: Int, CaseIterable {
case valueProps = 2
case createAccount = 3
case verifyEmail = 4
case joinResidence = 5 // Only for users joining with a code
case firstTask = 6
case subscriptionUpsell = 7
case joinResidence = 5 // Only for users joining with a code
case residenceLocation = 6 // ZIP code entry for regional templates
case firstTask = 7
case subscriptionUpsell = 8
var title: String {
switch self {
@@ -144,6 +174,8 @@ enum OnboardingStep: Int, CaseIterable {
return "Verify Email"
case .joinResidence:
return "Join Residence"
case .residenceLocation:
return "Your Location"
case .firstTask:
return "First Task"
case .subscriptionUpsell:

View File

@@ -190,7 +190,7 @@ struct OnboardingVerifyEmailContent: View {
}
// Resend code hint
HStack(spacing: 6) {
HStack(alignment: .top, spacing: 6) {
Image(systemName: "info.circle.fill")
.font(.system(size: 13))
.foregroundColor(Color.appTextSecondary.opacity(0.7))
@@ -198,8 +198,10 @@ struct OnboardingVerifyEmailContent: View {
Text("Didn't receive a code? Check your spam folder or re-register")
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 16)
.padding(.top, 8)
}

View File

@@ -157,6 +157,11 @@ struct RootView: View {
OnboardingCoordinator(onComplete: {
// Onboarding complete - mark verified and refresh the view
authManager.markVerified()
// Force refresh all data so tasks created during onboarding appear
Task {
_ = try? await APILayer.shared.getMyResidences(forceRefresh: true)
_ = try? await APILayer.shared.getTasks(forceRefresh: true)
}
refreshID = UUID()
})
Color.clear

View File

@@ -78,7 +78,15 @@ struct PropertyHeaderCard: View {
HStack(spacing: 10) {
Color.clear.frame(width: 32, height: 1) // Alignment spacer
Text("\(residence.city), \(residence.stateProvince) \(residence.postalCode)")
Text({
var parts: [String] = []
let cityState = [residence.city, residence.stateProvince]
.filter { !$0.isEmpty }
.joined(separator: ", ")
if !cityState.isEmpty { parts.append(cityState) }
if !residence.postalCode.isEmpty { parts.append(residence.postalCode) }
return parts.joined(separator: " ")
}())
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}