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:
@@ -713,6 +713,15 @@
|
|||||||
<string name="onboarding_feature_widgets_title">Home Screen Widgets</string>
|
<string name="onboarding_feature_widgets_title">Home Screen Widgets</string>
|
||||||
<string name="onboarding_feature_widgets_desc">Quick access to tasks and reminders directly from your home screen</string>
|
<string name="onboarding_feature_widgets_desc">Quick access to tasks and reminders directly from your home screen</string>
|
||||||
|
|
||||||
|
<!-- Onboarding - Location -->
|
||||||
|
<string name="onboarding_location_title">Where is your home?</string>
|
||||||
|
<string name="onboarding_location_subtitle">We\'ll suggest maintenance tasks specific to your area\'s climate</string>
|
||||||
|
<string name="onboarding_location_use_my_location">Use My Location</string>
|
||||||
|
<string name="onboarding_location_detecting">Detecting...</string>
|
||||||
|
<string name="onboarding_location_enter_zip">Enter ZIP code instead</string>
|
||||||
|
<string name="onboarding_location_enter_zip_prompt">Enter your ZIP code</string>
|
||||||
|
<string name="onboarding_location_zip_placeholder">ZIP Code</string>
|
||||||
|
|
||||||
<!-- Onboarding - Name Residence -->
|
<!-- Onboarding - Name Residence -->
|
||||||
<string name="onboarding_name_residence_title">Name Your Home</string>
|
<string name="onboarding_name_residence_title">Name Your Home</string>
|
||||||
<string name="onboarding_name_residence_subtitle">Give your property a name to help you identify it</string>
|
<string name="onboarding_name_residence_subtitle">Give your property a name to help you identify it</string>
|
||||||
|
|||||||
@@ -1192,6 +1192,15 @@ object APILayer {
|
|||||||
} ?: ApiResult.Error("Task template not found")
|
} ?: 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<List<TaskTemplate>> {
|
||||||
|
return taskTemplateApi.getTemplatesByRegion(state = state, zip = zip)
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Auth Operations ====================
|
// ==================== Auth Operations ====================
|
||||||
|
|
||||||
suspend fun login(request: LoginRequest): ApiResult<AuthResponse> {
|
suspend fun login(request: LoginRequest): ApiResult<AuthResponse> {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ package com.example.casera.network
|
|||||||
*/
|
*/
|
||||||
object ApiConfig {
|
object ApiConfig {
|
||||||
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
|
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
|
||||||
val CURRENT_ENV = Environment.DEV
|
val CURRENT_ENV = Environment.LOCAL
|
||||||
|
|
||||||
enum class Environment {
|
enum class Environment {
|
||||||
LOCAL,
|
LOCAL,
|
||||||
|
|||||||
@@ -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<List<TaskTemplate>> {
|
||||||
|
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
|
* Get a single template by ID
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,7 +104,7 @@ fun OnboardingScreen(
|
|||||||
viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE)
|
viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE)
|
||||||
} else {
|
} else {
|
||||||
viewModel.createResidence()
|
viewModel.createResidence()
|
||||||
viewModel.goToStep(OnboardingStep.FIRST_TASK)
|
viewModel.goToStep(OnboardingStep.RESIDENCE_LOCATION)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
viewModel.nextStep()
|
viewModel.nextStep()
|
||||||
@@ -119,7 +119,7 @@ fun OnboardingScreen(
|
|||||||
viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE)
|
viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE)
|
||||||
} else {
|
} else {
|
||||||
viewModel.createResidence()
|
viewModel.createResidence()
|
||||||
viewModel.goToStep(OnboardingStep.FIRST_TASK)
|
viewModel.goToStep(OnboardingStep.RESIDENCE_LOCATION)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -129,6 +129,15 @@ fun OnboardingScreen(
|
|||||||
onJoined = { viewModel.nextStep() }
|
onJoined = { viewModel.nextStep() }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
OnboardingStep.RESIDENCE_LOCATION -> OnboardingLocationContent(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onLocationDetected = { zip ->
|
||||||
|
viewModel.loadRegionalTemplates(zip)
|
||||||
|
viewModel.nextStep()
|
||||||
|
},
|
||||||
|
onSkip = { viewModel.nextStep() }
|
||||||
|
)
|
||||||
|
|
||||||
OnboardingStep.FIRST_TASK -> OnboardingFirstTaskContent(
|
OnboardingStep.FIRST_TASK -> OnboardingFirstTaskContent(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onTasksAdded = { viewModel.nextStep() }
|
onTasksAdded = { viewModel.nextStep() }
|
||||||
@@ -154,6 +163,7 @@ private fun OnboardingNavigationBar(
|
|||||||
val showBackButton = when (currentStep) {
|
val showBackButton = when (currentStep) {
|
||||||
OnboardingStep.WELCOME,
|
OnboardingStep.WELCOME,
|
||||||
OnboardingStep.JOIN_RESIDENCE,
|
OnboardingStep.JOIN_RESIDENCE,
|
||||||
|
OnboardingStep.RESIDENCE_LOCATION,
|
||||||
OnboardingStep.FIRST_TASK,
|
OnboardingStep.FIRST_TASK,
|
||||||
OnboardingStep.SUBSCRIPTION_UPSELL -> false
|
OnboardingStep.SUBSCRIPTION_UPSELL -> false
|
||||||
else -> true
|
else -> true
|
||||||
@@ -162,6 +172,7 @@ 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.FIRST_TASK,
|
OnboardingStep.FIRST_TASK,
|
||||||
OnboardingStep.SUBSCRIPTION_UPSELL -> true
|
OnboardingStep.SUBSCRIPTION_UPSELL -> true
|
||||||
else -> false
|
else -> false
|
||||||
@@ -170,6 +181,7 @@ private fun OnboardingNavigationBar(
|
|||||||
val showProgressIndicator = when (currentStep) {
|
val showProgressIndicator = when (currentStep) {
|
||||||
OnboardingStep.WELCOME,
|
OnboardingStep.WELCOME,
|
||||||
OnboardingStep.JOIN_RESIDENCE,
|
OnboardingStep.JOIN_RESIDENCE,
|
||||||
|
OnboardingStep.RESIDENCE_LOCATION,
|
||||||
OnboardingStep.FIRST_TASK,
|
OnboardingStep.FIRST_TASK,
|
||||||
OnboardingStep.SUBSCRIPTION_UPSELL -> false
|
OnboardingStep.SUBSCRIPTION_UPSELL -> false
|
||||||
else -> true
|
else -> true
|
||||||
@@ -182,6 +194,7 @@ private fun OnboardingNavigationBar(
|
|||||||
OnboardingStep.CREATE_ACCOUNT -> 3
|
OnboardingStep.CREATE_ACCOUNT -> 3
|
||||||
OnboardingStep.VERIFY_EMAIL -> 4
|
OnboardingStep.VERIFY_EMAIL -> 4
|
||||||
OnboardingStep.JOIN_RESIDENCE -> 4
|
OnboardingStep.JOIN_RESIDENCE -> 4
|
||||||
|
OnboardingStep.RESIDENCE_LOCATION -> 4
|
||||||
OnboardingStep.FIRST_TASK -> 4
|
OnboardingStep.FIRST_TASK -> 4
|
||||||
OnboardingStep.SUBSCRIPTION_UPSELL -> 4
|
OnboardingStep.SUBSCRIPTION_UPSELL -> 4
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.example.casera.models.LoginRequest
|
|||||||
import com.example.casera.models.RegisterRequest
|
import com.example.casera.models.RegisterRequest
|
||||||
import com.example.casera.models.ResidenceCreateRequest
|
import com.example.casera.models.ResidenceCreateRequest
|
||||||
import com.example.casera.models.TaskCreateRequest
|
import com.example.casera.models.TaskCreateRequest
|
||||||
|
import com.example.casera.models.TaskTemplate
|
||||||
import com.example.casera.models.VerifyEmailRequest
|
import com.example.casera.models.VerifyEmailRequest
|
||||||
import com.example.casera.network.ApiResult
|
import com.example.casera.network.ApiResult
|
||||||
import com.example.casera.network.APILayer
|
import com.example.casera.network.APILayer
|
||||||
@@ -35,6 +36,7 @@ enum class OnboardingStep {
|
|||||||
CREATE_ACCOUNT,
|
CREATE_ACCOUNT,
|
||||||
VERIFY_EMAIL,
|
VERIFY_EMAIL,
|
||||||
JOIN_RESIDENCE,
|
JOIN_RESIDENCE,
|
||||||
|
RESIDENCE_LOCATION,
|
||||||
FIRST_TASK,
|
FIRST_TASK,
|
||||||
SUBSCRIPTION_UPSELL
|
SUBSCRIPTION_UPSELL
|
||||||
}
|
}
|
||||||
@@ -80,6 +82,14 @@ class OnboardingViewModel : ViewModel() {
|
|||||||
private val _createTasksState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
private val _createTasksState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
||||||
val createTasksState: StateFlow<ApiResult<Unit>> = _createTasksState
|
val createTasksState: StateFlow<ApiResult<Unit>> = _createTasksState
|
||||||
|
|
||||||
|
// Regional templates state
|
||||||
|
private val _regionalTemplates = MutableStateFlow<ApiResult<List<TaskTemplate>>>(ApiResult.Idle)
|
||||||
|
val regionalTemplates: StateFlow<ApiResult<List<TaskTemplate>>> = _regionalTemplates
|
||||||
|
|
||||||
|
// ZIP code entered during location step (persisted on residence)
|
||||||
|
private val _postalCode = MutableStateFlow("")
|
||||||
|
val postalCode: StateFlow<String> = _postalCode
|
||||||
|
|
||||||
// Whether onboarding is complete
|
// Whether onboarding is complete
|
||||||
private val _isComplete = MutableStateFlow(false)
|
private val _isComplete = MutableStateFlow(false)
|
||||||
val isComplete: StateFlow<Boolean> = _isComplete
|
val isComplete: StateFlow<Boolean> = _isComplete
|
||||||
@@ -116,10 +126,11 @@ class OnboardingViewModel : ViewModel() {
|
|||||||
if (_userIntent.value == OnboardingIntent.JOIN_EXISTING) {
|
if (_userIntent.value == OnboardingIntent.JOIN_EXISTING) {
|
||||||
OnboardingStep.JOIN_RESIDENCE
|
OnboardingStep.JOIN_RESIDENCE
|
||||||
} else {
|
} else {
|
||||||
OnboardingStep.FIRST_TASK
|
OnboardingStep.RESIDENCE_LOCATION
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OnboardingStep.JOIN_RESIDENCE -> OnboardingStep.SUBSCRIPTION_UPSELL
|
OnboardingStep.JOIN_RESIDENCE -> OnboardingStep.SUBSCRIPTION_UPSELL
|
||||||
|
OnboardingStep.RESIDENCE_LOCATION -> OnboardingStep.FIRST_TASK
|
||||||
OnboardingStep.FIRST_TASK -> OnboardingStep.SUBSCRIPTION_UPSELL
|
OnboardingStep.FIRST_TASK -> OnboardingStep.SUBSCRIPTION_UPSELL
|
||||||
OnboardingStep.SUBSCRIPTION_UPSELL -> {
|
OnboardingStep.SUBSCRIPTION_UPSELL -> {
|
||||||
completeOnboarding()
|
completeOnboarding()
|
||||||
@@ -161,6 +172,7 @@ class OnboardingViewModel : ViewModel() {
|
|||||||
when (_currentStep.value) {
|
when (_currentStep.value) {
|
||||||
OnboardingStep.VALUE_PROPS,
|
OnboardingStep.VALUE_PROPS,
|
||||||
OnboardingStep.JOIN_RESIDENCE,
|
OnboardingStep.JOIN_RESIDENCE,
|
||||||
|
OnboardingStep.RESIDENCE_LOCATION,
|
||||||
OnboardingStep.FIRST_TASK -> nextStep()
|
OnboardingStep.FIRST_TASK -> nextStep()
|
||||||
OnboardingStep.SUBSCRIPTION_UPSELL -> completeOnboarding()
|
OnboardingStep.SUBSCRIPTION_UPSELL -> completeOnboarding()
|
||||||
else -> {}
|
else -> {}
|
||||||
@@ -250,7 +262,7 @@ class OnboardingViewModel : ViewModel() {
|
|||||||
apartmentUnit = null,
|
apartmentUnit = null,
|
||||||
city = null,
|
city = null,
|
||||||
stateProvince = null,
|
stateProvince = null,
|
||||||
postalCode = null,
|
postalCode = _postalCode.value.takeIf { it.isNotBlank() },
|
||||||
country = null,
|
country = null,
|
||||||
bedrooms = null,
|
bedrooms = null,
|
||||||
bathrooms = 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
|
* Mark onboarding as complete
|
||||||
*/
|
*/
|
||||||
@@ -336,6 +360,8 @@ class OnboardingViewModel : ViewModel() {
|
|||||||
_createResidenceState.value = ApiResult.Idle
|
_createResidenceState.value = ApiResult.Idle
|
||||||
_joinResidenceState.value = ApiResult.Idle
|
_joinResidenceState.value = ApiResult.Idle
|
||||||
_createTasksState.value = ApiResult.Idle
|
_createTasksState.value = ApiResult.Idle
|
||||||
|
_regionalTemplates.value = ApiResult.Idle
|
||||||
|
_postalCode.value = ""
|
||||||
_isComplete.value = false
|
_isComplete.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.",
|
"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
|
"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.",
|
"comment" : "An error message displayed when there was an issue loading tasks for a residence.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@@ -146,6 +134,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"12345" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"ABC123" : {
|
"ABC123" : {
|
||||||
|
|
||||||
@@ -5264,6 +5255,9 @@
|
|||||||
},
|
},
|
||||||
"CONFIRM PASSWORD" : {
|
"CONFIRM PASSWORD" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Continue" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Continue with Free" : {
|
"Continue with Free" : {
|
||||||
|
|
||||||
@@ -16992,6 +16986,9 @@
|
|||||||
"Enter your email address and we'll send you a verification code" : {
|
"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.",
|
"comment" : "A description below the email input field, instructing the user to enter their email address to receive a password reset code.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Enter your ZIP code so we can suggest\nmaintenance tasks for your climate region." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Error" : {
|
"Error" : {
|
||||||
"comment" : "The title of an alert that appears when there's an 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" : {
|
"Help improve Casera by sharing anonymous usage data" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Here are tasks recommended for your area.\nPick the ones you'd like to track!" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Hour" : {
|
"Hour" : {
|
||||||
"comment" : "A picker for selecting an hour.",
|
"comment" : "A picker for selecting an hour.",
|
||||||
@@ -30125,6 +30125,9 @@
|
|||||||
"Welcome to Your Space" : {
|
"Welcome to Your Space" : {
|
||||||
"comment" : "A welcoming message displayed at the top of the \"Organic Empty Residences\" view.",
|
"comment" : "A welcoming message displayed at the top of the \"Organic Empty Residences\" view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Where's your home?" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"You now have access to %@." : {
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -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) {
|
private func createResidenceIfNeeded(thenNavigateTo step: OnboardingStep) {
|
||||||
print("🏠 ONBOARDING: createResidenceIfNeeded called")
|
print("🏠 ONBOARDING: createResidenceIfNeeded called")
|
||||||
print("🏠 ONBOARDING: userIntent = \(onboardingState.userIntent)")
|
print("🏠 ONBOARDING: userIntent = \(onboardingState.userIntent)")
|
||||||
@@ -66,7 +66,8 @@ struct OnboardingCoordinator: View {
|
|||||||
return
|
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
|
isCreatingResidence = true
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ struct OnboardingCoordinator: View {
|
|||||||
apartmentUnit: nil,
|
apartmentUnit: nil,
|
||||||
city: nil,
|
city: nil,
|
||||||
stateProvince: nil,
|
stateProvince: nil,
|
||||||
postalCode: nil,
|
postalCode: postalCode,
|
||||||
country: nil,
|
country: nil,
|
||||||
bedrooms: nil,
|
bedrooms: nil,
|
||||||
bathrooms: nil,
|
bathrooms: nil,
|
||||||
@@ -104,7 +105,7 @@ struct OnboardingCoordinator: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Current step index for progress indicator (0-based)
|
/// 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 {
|
private var currentProgressStep: Int {
|
||||||
switch onboardingState.currentStep {
|
switch onboardingState.currentStep {
|
||||||
case .welcome: return 0
|
case .welcome: return 0
|
||||||
@@ -113,6 +114,7 @@ struct OnboardingCoordinator: View {
|
|||||||
case .createAccount: return 3
|
case .createAccount: return 3
|
||||||
case .verifyEmail: return 4
|
case .verifyEmail: return 4
|
||||||
case .joinResidence: return 4
|
case .joinResidence: return 4
|
||||||
|
case .residenceLocation: return 4
|
||||||
case .firstTask: return 4
|
case .firstTask: return 4
|
||||||
case .subscriptionUpsell: return 4
|
case .subscriptionUpsell: return 4
|
||||||
}
|
}
|
||||||
@@ -121,7 +123,7 @@ struct OnboardingCoordinator: View {
|
|||||||
/// Whether to show the back button
|
/// Whether to show the back button
|
||||||
private var showBackButton: Bool {
|
private var showBackButton: Bool {
|
||||||
switch onboardingState.currentStep {
|
switch onboardingState.currentStep {
|
||||||
case .welcome, .joinResidence, .firstTask, .subscriptionUpsell:
|
case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
|
||||||
return false
|
return false
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
@@ -131,7 +133,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, .firstTask, .subscriptionUpsell:
|
case .valueProps, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -141,7 +143,7 @@ struct OnboardingCoordinator: View {
|
|||||||
/// Whether to show the progress indicator
|
/// Whether to show the progress indicator
|
||||||
private var showProgressIndicator: Bool {
|
private var showProgressIndicator: Bool {
|
||||||
switch onboardingState.currentStep {
|
switch onboardingState.currentStep {
|
||||||
case .welcome, .joinResidence, .firstTask, .subscriptionUpsell:
|
case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
|
||||||
return false
|
return false
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
@@ -173,6 +175,9 @@ struct OnboardingCoordinator: View {
|
|||||||
switch onboardingState.currentStep {
|
switch onboardingState.currentStep {
|
||||||
case .valueProps:
|
case .valueProps:
|
||||||
goForward()
|
goForward()
|
||||||
|
case .residenceLocation:
|
||||||
|
// Skipping location — still need to create residence (without postal code)
|
||||||
|
createResidenceIfNeeded(thenNavigateTo: .firstTask)
|
||||||
case .joinResidence, .firstTask:
|
case .joinResidence, .firstTask:
|
||||||
goForward()
|
goForward()
|
||||||
case .subscriptionUpsell:
|
case .subscriptionUpsell:
|
||||||
@@ -187,13 +192,14 @@ struct OnboardingCoordinator: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Shared navigation bar - stays static
|
// Shared navigation bar - stays static
|
||||||
HStack {
|
HStack {
|
||||||
// Back button
|
// Back button — fixed width so progress dots stay centered
|
||||||
Button(action: handleBack) {
|
Button(action: handleBack) {
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundColor(Color.appPrimary)
|
.foregroundColor(Color.appPrimary)
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.backButton)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.backButton)
|
||||||
|
.frame(width: 44, alignment: .leading)
|
||||||
.opacity(showBackButton ? 1 : 0)
|
.opacity(showBackButton ? 1 : 0)
|
||||||
.disabled(!showBackButton)
|
.disabled(!showBackButton)
|
||||||
|
|
||||||
@@ -207,7 +213,7 @@ struct OnboardingCoordinator: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Skip button
|
// Skip button — fixed width to match back button
|
||||||
Button(action: handleSkip) {
|
Button(action: handleSkip) {
|
||||||
Text("Skip")
|
Text("Skip")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
@@ -215,6 +221,7 @@ struct OnboardingCoordinator: View {
|
|||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.skipButton)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.skipButton)
|
||||||
|
.frame(width: 44, alignment: .trailing)
|
||||||
.opacity(showSkipButton ? 1 : 0)
|
.opacity(showSkipButton ? 1 : 0)
|
||||||
.disabled(!showSkipButton)
|
.disabled(!showSkipButton)
|
||||||
}
|
}
|
||||||
@@ -268,7 +275,7 @@ struct OnboardingCoordinator: View {
|
|||||||
if onboardingState.userIntent == .joinExisting {
|
if onboardingState.userIntent == .joinExisting {
|
||||||
goForward(to: .joinResidence)
|
goForward(to: .joinResidence)
|
||||||
} else {
|
} else {
|
||||||
createResidenceIfNeeded(thenNavigateTo: .firstTask)
|
goForward(to: .residenceLocation)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
goForward()
|
goForward()
|
||||||
@@ -281,13 +288,10 @@ struct OnboardingCoordinator: View {
|
|||||||
OnboardingVerifyEmailContent(
|
OnboardingVerifyEmailContent(
|
||||||
onVerified: {
|
onVerified: {
|
||||||
print("🏠 ONBOARDING: onVerified callback triggered in coordinator")
|
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 {
|
if onboardingState.userIntent == .joinExisting {
|
||||||
goForward(to: .joinResidence)
|
goForward(to: .joinResidence)
|
||||||
} else {
|
} else {
|
||||||
createResidenceIfNeeded(thenNavigateTo: .firstTask)
|
goForward(to: .residenceLocation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -301,6 +305,20 @@ struct OnboardingCoordinator: View {
|
|||||||
)
|
)
|
||||||
.transition(navigationTransition)
|
.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:
|
case .firstTask:
|
||||||
OnboardingFirstTaskContent(
|
OnboardingFirstTaskContent(
|
||||||
residenceName: onboardingState.pendingResidenceName,
|
residenceName: onboardingState.pendingResidenceName,
|
||||||
|
|||||||
@@ -11,14 +11,91 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
@ObservedObject private var onboardingState = OnboardingState.shared
|
@ObservedObject private var onboardingState = OnboardingState.shared
|
||||||
@State private var selectedTasks: Set<UUID> = []
|
@State private var selectedTasks: Set<UUID> = []
|
||||||
@State private var isCreatingTasks = false
|
@State private var isCreatingTasks = false
|
||||||
@State private var expandedCategory: String? = nil
|
@State private var expandedCategories: Set<String> = []
|
||||||
@State private var isAnimating = false
|
@State private var isAnimating = false
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
/// Maximum tasks allowed for free tier (matches API TierLimits)
|
/// Maximum tasks allowed for free tier (matches API TierLimits)
|
||||||
private let maxTasksAllowed = 5
|
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(
|
OnboardingTaskCategory(
|
||||||
name: "HVAC & Climate",
|
name: "HVAC & Climate",
|
||||||
icon: "thermometer.medium",
|
icon: "thermometer.medium",
|
||||||
@@ -141,6 +218,7 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(spacing: OrganicSpacing.comfortable) {
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
// Header with celebration
|
// Header with celebration
|
||||||
@@ -208,7 +286,9 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.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))
|
.font(.system(size: 15, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -237,18 +317,27 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
OrganicTaskCategorySection(
|
OrganicTaskCategorySection(
|
||||||
category: category,
|
category: category,
|
||||||
selectedTasks: $selectedTasks,
|
selectedTasks: $selectedTasks,
|
||||||
isExpanded: expandedCategory == category.name,
|
isExpanded: expandedCategories.contains(category.name),
|
||||||
isAtMaxSelection: isAtMaxSelection,
|
isAtMaxSelection: isAtMaxSelection,
|
||||||
onToggleExpand: {
|
onToggleExpand: {
|
||||||
|
let isExpanding = !expandedCategories.contains(category.name)
|
||||||
withAnimation(.spring(response: 0.3)) {
|
withAnimation(.spring(response: 0.3)) {
|
||||||
if expandedCategory == category.name {
|
if expandedCategories.contains(category.name) {
|
||||||
expandedCategory = nil
|
expandedCategories.remove(category.name)
|
||||||
} else {
|
} 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)
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
@@ -295,6 +384,7 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
}
|
}
|
||||||
.padding(.bottom, 140) // Space for button
|
.padding(.bottom, 140) // Space for button
|
||||||
}
|
}
|
||||||
|
} // ScrollViewReader
|
||||||
|
|
||||||
// Bottom action area
|
// Bottom action area
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
@@ -341,8 +431,18 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
isAnimating = true
|
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
|
// Expand first category by default
|
||||||
expandedCategory = taskCategories.first?.name
|
if let first = taskCategories.first?.name {
|
||||||
|
expandedCategories.insert(first)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
isAnimating = false
|
isAnimating = false
|
||||||
@@ -350,19 +450,27 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func selectPopularTasks() {
|
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)) {
|
withAnimation(.spring(response: 0.3)) {
|
||||||
for task in allTasks where popularTaskTitles.contains(task.title) {
|
if !onboardingState.regionalTemplates.isEmpty {
|
||||||
if selectedTasks.count < maxTasksAllowed {
|
// API templates: select the first N tasks (they're ordered by display_order)
|
||||||
selectedTasks.insert(task.id)
|
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
|
var completedCount = 0
|
||||||
let totalCount = selectedTemplates.count
|
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
|
// Format today's date as YYYY-MM-DD for the API
|
||||||
let dateFormatter = DateFormatter()
|
let dateFormatter = DateFormatter()
|
||||||
dateFormatter.dateFormat = "yyyy-MM-dd"
|
dateFormatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
|||||||
251
iosApp/iosApp/Onboarding/OnboardingLocationView.swift
Normal file
251
iosApp/iosApp/Onboarding/OnboardingLocationView.swift
Normal 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: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -36,6 +36,15 @@ 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
|
||||||
|
|
||||||
/// The user's selected intent (start fresh or join existing).
|
/// The user's selected intent (start fresh or join existing).
|
||||||
/// Reads/writes the persisted @AppStorage value and notifies SwiftUI of the change.
|
/// Reads/writes the persisted @AppStorage value and notifies SwiftUI of the change.
|
||||||
var userIntent: OnboardingIntent {
|
var userIntent: OnboardingIntent {
|
||||||
@@ -54,6 +63,20 @@ 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 {
|
||||||
|
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
|
/// Start the onboarding flow
|
||||||
func startOnboarding() {
|
func startOnboarding() {
|
||||||
isOnboardingActive = true
|
isOnboardingActive = true
|
||||||
@@ -62,17 +85,17 @@ class OnboardingState: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Move to the next step in the flow
|
/// 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() {
|
func nextStep() {
|
||||||
switch currentStep {
|
switch currentStep {
|
||||||
case .welcome:
|
case .welcome:
|
||||||
if userIntent == .joinExisting {
|
if userIntent == .joinExisting {
|
||||||
currentStep = .createAccount
|
currentStep = .createAccount
|
||||||
} else {
|
} else {
|
||||||
currentStep = .valueProps // Features first to wow the user
|
currentStep = .valueProps
|
||||||
}
|
}
|
||||||
case .valueProps:
|
case .valueProps:
|
||||||
currentStep = .nameResidence // Then name the house
|
currentStep = .nameResidence
|
||||||
case .nameResidence:
|
case .nameResidence:
|
||||||
currentStep = .createAccount
|
currentStep = .createAccount
|
||||||
case .createAccount:
|
case .createAccount:
|
||||||
@@ -81,10 +104,12 @@ class OnboardingState: ObservableObject {
|
|||||||
if userIntent == .joinExisting {
|
if userIntent == .joinExisting {
|
||||||
currentStep = .joinResidence
|
currentStep = .joinResidence
|
||||||
} else {
|
} else {
|
||||||
currentStep = .firstTask
|
currentStep = .residenceLocation
|
||||||
}
|
}
|
||||||
case .joinResidence:
|
case .joinResidence:
|
||||||
currentStep = .subscriptionUpsell
|
currentStep = .subscriptionUpsell
|
||||||
|
case .residenceLocation:
|
||||||
|
currentStep = .firstTask
|
||||||
case .firstTask:
|
case .firstTask:
|
||||||
currentStep = .subscriptionUpsell
|
currentStep = .subscriptionUpsell
|
||||||
case .subscriptionUpsell:
|
case .subscriptionUpsell:
|
||||||
@@ -103,6 +128,8 @@ class OnboardingState: ObservableObject {
|
|||||||
hasCompletedOnboarding = true
|
hasCompletedOnboarding = true
|
||||||
isOnboardingActive = false
|
isOnboardingActive = false
|
||||||
pendingResidenceName = ""
|
pendingResidenceName = ""
|
||||||
|
pendingPostalCode = ""
|
||||||
|
regionalTemplates = []
|
||||||
createdResidenceId = nil
|
createdResidenceId = nil
|
||||||
userIntent = .unknown
|
userIntent = .unknown
|
||||||
}
|
}
|
||||||
@@ -113,6 +140,8 @@ 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
|
||||||
@@ -126,9 +155,10 @@ enum OnboardingStep: Int, CaseIterable {
|
|||||||
case valueProps = 2
|
case valueProps = 2
|
||||||
case createAccount = 3
|
case createAccount = 3
|
||||||
case verifyEmail = 4
|
case verifyEmail = 4
|
||||||
case joinResidence = 5 // Only for users joining with a code
|
case joinResidence = 5 // Only for users joining with a code
|
||||||
case firstTask = 6
|
case residenceLocation = 6 // ZIP code entry for regional templates
|
||||||
case subscriptionUpsell = 7
|
case firstTask = 7
|
||||||
|
case subscriptionUpsell = 8
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -144,6 +174,8 @@ enum OnboardingStep: Int, CaseIterable {
|
|||||||
return "Verify Email"
|
return "Verify Email"
|
||||||
case .joinResidence:
|
case .joinResidence:
|
||||||
return "Join Residence"
|
return "Join Residence"
|
||||||
|
case .residenceLocation:
|
||||||
|
return "Your Location"
|
||||||
case .firstTask:
|
case .firstTask:
|
||||||
return "First Task"
|
return "First Task"
|
||||||
case .subscriptionUpsell:
|
case .subscriptionUpsell:
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ struct OnboardingVerifyEmailContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resend code hint
|
// Resend code hint
|
||||||
HStack(spacing: 6) {
|
HStack(alignment: .top, spacing: 6) {
|
||||||
Image(systemName: "info.circle.fill")
|
Image(systemName: "info.circle.fill")
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
.foregroundColor(Color.appTextSecondary.opacity(0.7))
|
.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")
|
Text("Didn't receive a code? Check your spam folder or re-register")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.system(size: 13, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.leading)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,11 @@ struct RootView: View {
|
|||||||
OnboardingCoordinator(onComplete: {
|
OnboardingCoordinator(onComplete: {
|
||||||
// Onboarding complete - mark verified and refresh the view
|
// Onboarding complete - mark verified and refresh the view
|
||||||
authManager.markVerified()
|
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()
|
refreshID = UUID()
|
||||||
})
|
})
|
||||||
Color.clear
|
Color.clear
|
||||||
|
|||||||
@@ -78,7 +78,15 @@ struct PropertyHeaderCard: View {
|
|||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Color.clear.frame(width: 32, height: 1) // Alignment spacer
|
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))
|
.font(.system(size: 14, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user