Add regional task templates to onboarding with multiple bug fixes

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

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

View File

@@ -713,6 +713,15 @@
<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>
<!-- 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 -->
<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>

View File

@@ -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<List<TaskTemplate>> {
return taskTemplateApi.getTemplatesByRegion(state = state, zip = zip)
}
// ==================== Auth Operations ====================
suspend fun login(request: LoginRequest): ApiResult<AuthResponse> {

View File

@@ -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,

View File

@@ -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
*/

View File

@@ -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))
}
}
}

View File

@@ -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
}

View File

@@ -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<Unit>>(ApiResult.Idle)
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
private val _isComplete = MutableStateFlow(false)
val isComplete: StateFlow<Boolean> = _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
}