diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 99862c0..92a57eb 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -713,6 +713,15 @@
Home Screen Widgets
Quick access to tasks and reminders directly from your home screen
+
+ Where is your home?
+ We\'ll suggest maintenance tasks specific to your area\'s climate
+ Use My Location
+ Detecting...
+ Enter ZIP code instead
+ Enter your ZIP code
+ ZIP Code
+
Name Your Home
Give your property a name to help you identify it
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt
index ca6cd2b..45b558c 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt
@@ -1192,6 +1192,15 @@ object APILayer {
} ?: ApiResult.Error("Task template not found")
}
+ /**
+ * Get task templates filtered by climate region.
+ * Accepts either a state abbreviation or ZIP code — backend resolves to climate zone.
+ * This calls the API directly since regional templates are not cached in seeded data.
+ */
+ suspend fun getRegionalTemplates(state: String? = null, zip: String? = null): ApiResult> {
+ return taskTemplateApi.getTemplatesByRegion(state = state, zip = zip)
+ }
+
// ==================== Auth Operations ====================
suspend fun login(request: LoginRequest): ApiResult {
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt
index f4bc03e..c190066 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt
@@ -9,7 +9,7 @@ package com.example.casera.network
*/
object ApiConfig {
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
- val CURRENT_ENV = Environment.DEV
+ val CURRENT_ENV = Environment.LOCAL
enum class Environment {
LOCAL,
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/TaskTemplateApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/TaskTemplateApi.kt
index d0ea050..38209c2 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/network/TaskTemplateApi.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/TaskTemplateApi.kt
@@ -84,6 +84,27 @@ class TaskTemplateApi(private val client: HttpClient = ApiClient.httpClient) {
}
}
+ /**
+ * Get templates filtered by climate region.
+ * Accepts either a state abbreviation or ZIP code — backend resolves to climate zone.
+ */
+ suspend fun getTemplatesByRegion(state: String? = null, zip: String? = null): ApiResult> {
+ return try {
+ val response = client.get("$baseUrl/tasks/templates/by-region/") {
+ state?.let { parameter("state", it) }
+ zip?.let { parameter("zip", it) }
+ }
+
+ if (response.status.isSuccess()) {
+ ApiResult.Success(response.body())
+ } else {
+ ApiResult.Error("Failed to fetch regional templates", response.status.value)
+ }
+ } catch (e: Exception) {
+ ApiResult.Error(e.message ?: "Unknown error occurred")
+ }
+ }
+
/**
* Get a single template by ID
*/
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingLocationContent.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingLocationContent.kt
new file mode 100644
index 0000000..a2a9427
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingLocationContent.kt
@@ -0,0 +1,133 @@
+package com.example.casera.ui.screens.onboarding
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowForward
+import androidx.compose.material.icons.filled.LocationOn
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.example.casera.ui.theme.*
+import com.example.casera.viewmodel.OnboardingViewModel
+import casera.composeapp.generated.resources.*
+import org.jetbrains.compose.resources.stringResource
+
+/**
+ * Onboarding step that collects the user's ZIP code to suggest
+ * region-specific maintenance tasks. The backend handles all
+ * ZIP → state → climate zone resolution.
+ */
+@Composable
+fun OnboardingLocationContent(
+ viewModel: OnboardingViewModel,
+ onLocationDetected: (String) -> Unit,
+ onSkip: () -> Unit
+) {
+ var zipCode by remember { mutableStateOf("") }
+
+ WarmGradientBackground(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = OrganicSpacing.xl),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Header with icon
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(OrganicSpacing.lg)
+ ) {
+ OrganicIconContainer(
+ icon = Icons.Default.LocationOn,
+ size = 100.dp,
+ iconSize = 50.dp,
+ contentDescription = null
+ )
+
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
+ ) {
+ Text(
+ text = stringResource(Res.string.onboarding_location_title),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onBackground,
+ textAlign = TextAlign.Center
+ )
+
+ Text(
+ text = stringResource(Res.string.onboarding_location_subtitle),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(OrganicSpacing.xl * 2))
+
+ // ZIP code input
+ Text(
+ text = stringResource(Res.string.onboarding_location_enter_zip_prompt),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontWeight = FontWeight.Medium
+ )
+
+ Spacer(modifier = Modifier.height(OrganicSpacing.md))
+
+ OutlinedTextField(
+ value = zipCode,
+ onValueChange = { newValue ->
+ // Only allow digits, max 5 characters
+ if (newValue.length <= 5 && newValue.all { it.isDigit() }) {
+ zipCode = newValue
+ }
+ },
+ placeholder = {
+ Text(
+ stringResource(Res.string.onboarding_location_zip_placeholder),
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+ )
+ },
+ modifier = Modifier.width(180.dp),
+ shape = RoundedCornerShape(OrganicRadius.md),
+ singleLine = true,
+ textStyle = MaterialTheme.typography.headlineSmall.copy(
+ fontWeight = FontWeight.SemiBold,
+ textAlign = TextAlign.Center
+ ),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = MaterialTheme.colorScheme.primary,
+ unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
+ )
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Continue button — passes ZIP directly to backend for resolution
+ OrganicPrimaryButton(
+ text = stringResource(Res.string.onboarding_continue),
+ onClick = { onLocationDetected(zipCode) },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = zipCode.length == 5,
+ icon = Icons.Default.ArrowForward
+ )
+
+ Spacer(modifier = Modifier.height(OrganicSpacing.xl * 2))
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingScreen.kt
index bc48b9b..d1aec9e 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingScreen.kt
@@ -104,7 +104,7 @@ fun OnboardingScreen(
viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE)
} else {
viewModel.createResidence()
- viewModel.goToStep(OnboardingStep.FIRST_TASK)
+ viewModel.goToStep(OnboardingStep.RESIDENCE_LOCATION)
}
} else {
viewModel.nextStep()
@@ -119,7 +119,7 @@ fun OnboardingScreen(
viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE)
} else {
viewModel.createResidence()
- viewModel.goToStep(OnboardingStep.FIRST_TASK)
+ viewModel.goToStep(OnboardingStep.RESIDENCE_LOCATION)
}
}
)
@@ -129,6 +129,15 @@ fun OnboardingScreen(
onJoined = { viewModel.nextStep() }
)
+ OnboardingStep.RESIDENCE_LOCATION -> OnboardingLocationContent(
+ viewModel = viewModel,
+ onLocationDetected = { zip ->
+ viewModel.loadRegionalTemplates(zip)
+ viewModel.nextStep()
+ },
+ onSkip = { viewModel.nextStep() }
+ )
+
OnboardingStep.FIRST_TASK -> OnboardingFirstTaskContent(
viewModel = viewModel,
onTasksAdded = { viewModel.nextStep() }
@@ -154,6 +163,7 @@ private fun OnboardingNavigationBar(
val showBackButton = when (currentStep) {
OnboardingStep.WELCOME,
OnboardingStep.JOIN_RESIDENCE,
+ OnboardingStep.RESIDENCE_LOCATION,
OnboardingStep.FIRST_TASK,
OnboardingStep.SUBSCRIPTION_UPSELL -> false
else -> true
@@ -162,6 +172,7 @@ private fun OnboardingNavigationBar(
val showSkipButton = when (currentStep) {
OnboardingStep.VALUE_PROPS,
OnboardingStep.JOIN_RESIDENCE,
+ OnboardingStep.RESIDENCE_LOCATION,
OnboardingStep.FIRST_TASK,
OnboardingStep.SUBSCRIPTION_UPSELL -> true
else -> false
@@ -170,6 +181,7 @@ private fun OnboardingNavigationBar(
val showProgressIndicator = when (currentStep) {
OnboardingStep.WELCOME,
OnboardingStep.JOIN_RESIDENCE,
+ OnboardingStep.RESIDENCE_LOCATION,
OnboardingStep.FIRST_TASK,
OnboardingStep.SUBSCRIPTION_UPSELL -> false
else -> true
@@ -182,6 +194,7 @@ private fun OnboardingNavigationBar(
OnboardingStep.CREATE_ACCOUNT -> 3
OnboardingStep.VERIFY_EMAIL -> 4
OnboardingStep.JOIN_RESIDENCE -> 4
+ OnboardingStep.RESIDENCE_LOCATION -> 4
OnboardingStep.FIRST_TASK -> 4
OnboardingStep.SUBSCRIPTION_UPSELL -> 4
}
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/OnboardingViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/OnboardingViewModel.kt
index b33728b..cf66d3e 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/OnboardingViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/OnboardingViewModel.kt
@@ -8,6 +8,7 @@ import com.example.casera.models.LoginRequest
import com.example.casera.models.RegisterRequest
import com.example.casera.models.ResidenceCreateRequest
import com.example.casera.models.TaskCreateRequest
+import com.example.casera.models.TaskTemplate
import com.example.casera.models.VerifyEmailRequest
import com.example.casera.network.ApiResult
import com.example.casera.network.APILayer
@@ -35,6 +36,7 @@ enum class OnboardingStep {
CREATE_ACCOUNT,
VERIFY_EMAIL,
JOIN_RESIDENCE,
+ RESIDENCE_LOCATION,
FIRST_TASK,
SUBSCRIPTION_UPSELL
}
@@ -80,6 +82,14 @@ class OnboardingViewModel : ViewModel() {
private val _createTasksState = MutableStateFlow>(ApiResult.Idle)
val createTasksState: StateFlow> = _createTasksState
+ // Regional templates state
+ private val _regionalTemplates = MutableStateFlow>>(ApiResult.Idle)
+ val regionalTemplates: StateFlow>> = _regionalTemplates
+
+ // ZIP code entered during location step (persisted on residence)
+ private val _postalCode = MutableStateFlow("")
+ val postalCode: StateFlow = _postalCode
+
// Whether onboarding is complete
private val _isComplete = MutableStateFlow(false)
val isComplete: StateFlow = _isComplete
@@ -116,10 +126,11 @@ class OnboardingViewModel : ViewModel() {
if (_userIntent.value == OnboardingIntent.JOIN_EXISTING) {
OnboardingStep.JOIN_RESIDENCE
} else {
- OnboardingStep.FIRST_TASK
+ OnboardingStep.RESIDENCE_LOCATION
}
}
OnboardingStep.JOIN_RESIDENCE -> OnboardingStep.SUBSCRIPTION_UPSELL
+ OnboardingStep.RESIDENCE_LOCATION -> OnboardingStep.FIRST_TASK
OnboardingStep.FIRST_TASK -> OnboardingStep.SUBSCRIPTION_UPSELL
OnboardingStep.SUBSCRIPTION_UPSELL -> {
completeOnboarding()
@@ -161,6 +172,7 @@ class OnboardingViewModel : ViewModel() {
when (_currentStep.value) {
OnboardingStep.VALUE_PROPS,
OnboardingStep.JOIN_RESIDENCE,
+ OnboardingStep.RESIDENCE_LOCATION,
OnboardingStep.FIRST_TASK -> nextStep()
OnboardingStep.SUBSCRIPTION_UPSELL -> completeOnboarding()
else -> {}
@@ -250,7 +262,7 @@ class OnboardingViewModel : ViewModel() {
apartmentUnit = null,
city = null,
stateProvince = null,
- postalCode = null,
+ postalCode = _postalCode.value.takeIf { it.isNotBlank() },
country = null,
bedrooms = null,
bathrooms = null,
@@ -315,6 +327,18 @@ class OnboardingViewModel : ViewModel() {
}
}
+ /**
+ * Load regional templates by ZIP code (backend resolves ZIP → state → climate zone).
+ * Also stores the ZIP code for later use when creating the residence.
+ */
+ fun loadRegionalTemplates(zip: String) {
+ _postalCode.value = zip
+ viewModelScope.launch {
+ _regionalTemplates.value = ApiResult.Loading
+ _regionalTemplates.value = APILayer.getRegionalTemplates(zip = zip)
+ }
+ }
+
/**
* Mark onboarding as complete
*/
@@ -336,6 +360,8 @@ class OnboardingViewModel : ViewModel() {
_createResidenceState.value = ApiResult.Idle
_joinResidenceState.value = ApiResult.Idle
_createTasksState.value = ApiResult.Idle
+ _regionalTemplates.value = ApiResult.Idle
+ _postalCode.value = ""
_isComplete.value = false
}
diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings
index 756db24..a00c8da 100644
--- a/iosApp/iosApp/Localizable.xcstrings
+++ b/iosApp/iosApp/Localizable.xcstrings
@@ -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.",
diff --git a/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift b/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift
index e3758cc..c8d6fa7 100644
--- a/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift
+++ b/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift
@@ -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,
diff --git a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift
index a7837f1..ca1ae8a 100644
--- a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift
+++ b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift
@@ -11,14 +11,91 @@ struct OnboardingFirstTaskContent: View {
@ObservedObject private var onboardingState = OnboardingState.shared
@State private var selectedTasks: Set = []
@State private var isCreatingTasks = false
- @State private var expandedCategory: String? = nil
+ @State private var expandedCategories: Set = []
@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"
diff --git a/iosApp/iosApp/Onboarding/OnboardingLocationView.swift b/iosApp/iosApp/Onboarding/OnboardingLocationView.swift
new file mode 100644
index 0000000..562e9a3
--- /dev/null
+++ b/iosApp/iosApp/Onboarding/OnboardingLocationView.swift
@@ -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: {}
+ )
+}
diff --git a/iosApp/iosApp/Onboarding/OnboardingState.swift b/iosApp/iosApp/Onboarding/OnboardingState.swift
index 27fd7ee..7c2e699 100644
--- a/iosApp/iosApp/Onboarding/OnboardingState.swift
+++ b/iosApp/iosApp/Onboarding/OnboardingState.swift
@@ -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,
+ 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:
diff --git a/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift b/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift
index 6dbcb40..e407fc0 100644
--- a/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift
+++ b/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift
@@ -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)
}
diff --git a/iosApp/iosApp/RootView.swift b/iosApp/iosApp/RootView.swift
index 7b1b9bc..06937cb 100644
--- a/iosApp/iosApp/RootView.swift
+++ b/iosApp/iosApp/RootView.swift
@@ -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
diff --git a/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift b/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift
index 05ab601..4a07171 100644
--- a/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift
+++ b/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift
@@ -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)
}