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_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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
} 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user