diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 0401234..ef6571c 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -813,6 +813,18 @@
Continue with Free
7-day free trial, then %1$s. Cancel anytime.
+
+ Tell us about your home
+ All optional — helps us personalize your maintenance plan
+ Systems
+ Features
+ Exterior
+ Interior
+
+
+ For You
+ Browse
+
App Locked
Authenticate to unlock honeyDue
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/HomeProfile.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/HomeProfile.kt
new file mode 100644
index 0000000..04b2074
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/HomeProfile.kt
@@ -0,0 +1,59 @@
+package com.tt.honeyDue.models
+
+/**
+ * Static option lists for home profile pickers.
+ * Each entry is a (apiValue, displayLabel) pair.
+ */
+object HomeProfileOptions {
+ val heatingTypes = listOf(
+ "gas_furnace" to "Gas Furnace",
+ "electric" to "Electric",
+ "heat_pump" to "Heat Pump",
+ "boiler" to "Boiler",
+ "radiant" to "Radiant",
+ "wood_stove" to "Wood Stove",
+ "none" to "None"
+ )
+ val coolingTypes = listOf(
+ "central_ac" to "Central AC",
+ "window_unit" to "Window Unit",
+ "mini_split" to "Mini Split",
+ "evaporative" to "Evaporative",
+ "none" to "None"
+ )
+ val waterHeaterTypes = listOf(
+ "tank_gas" to "Tank (Gas)",
+ "tank_electric" to "Tank (Electric)",
+ "tankless" to "Tankless",
+ "solar" to "Solar",
+ "heat_pump_wh" to "Heat Pump"
+ )
+ val roofTypes = listOf(
+ "asphalt_shingle" to "Asphalt Shingle",
+ "metal" to "Metal",
+ "tile" to "Tile",
+ "flat_tpo" to "Flat/TPO",
+ "slate" to "Slate",
+ "wood_shake" to "Wood Shake"
+ )
+ val exteriorTypes = listOf(
+ "vinyl_siding" to "Vinyl Siding",
+ "brick" to "Brick",
+ "stucco" to "Stucco",
+ "wood" to "Wood",
+ "stone" to "Stone",
+ "fiber_cement" to "Fiber Cement"
+ )
+ val flooringTypes = listOf(
+ "hardwood" to "Hardwood",
+ "carpet" to "Carpet",
+ "tile" to "Tile",
+ "laminate" to "Laminate",
+ "vinyl" to "Vinyl"
+ )
+ val landscapingTypes = listOf(
+ "lawn" to "Lawn",
+ "xeriscaping" to "Xeriscaping",
+ "none" to "None"
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Residence.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Residence.kt
index 5024d63..bdb5c68 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Residence.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Residence.kt
@@ -53,6 +53,20 @@ data class ResidenceResponse(
@SerialName("is_active") val isActive: Boolean = true,
@SerialName("overdue_count") val overdueCount: Int = 0,
@SerialName("completion_summary") val completionSummary: CompletionSummary? = null,
+ @SerialName("heating_type") val heatingType: String? = null,
+ @SerialName("cooling_type") val coolingType: String? = null,
+ @SerialName("water_heater_type") val waterHeaterType: String? = null,
+ @SerialName("roof_type") val roofType: String? = null,
+ @SerialName("has_pool") val hasPool: Boolean = false,
+ @SerialName("has_sprinkler_system") val hasSprinklerSystem: Boolean = false,
+ @SerialName("has_septic") val hasSeptic: Boolean = false,
+ @SerialName("has_fireplace") val hasFireplace: Boolean = false,
+ @SerialName("has_garage") val hasGarage: Boolean = false,
+ @SerialName("has_basement") val hasBasement: Boolean = false,
+ @SerialName("has_attic") val hasAttic: Boolean = false,
+ @SerialName("exterior_type") val exteriorType: String? = null,
+ @SerialName("flooring_primary") val flooringPrimary: String? = null,
+ @SerialName("landscaping_type") val landscapingType: String? = null,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String
) {
@@ -94,7 +108,21 @@ data class ResidenceCreateRequest(
val description: String? = null,
@SerialName("purchase_date") val purchaseDate: String? = null,
@SerialName("purchase_price") val purchasePrice: Double? = null,
- @SerialName("is_primary") val isPrimary: Boolean? = null
+ @SerialName("is_primary") val isPrimary: Boolean? = null,
+ @SerialName("heating_type") val heatingType: String? = null,
+ @SerialName("cooling_type") val coolingType: String? = null,
+ @SerialName("water_heater_type") val waterHeaterType: String? = null,
+ @SerialName("roof_type") val roofType: String? = null,
+ @SerialName("has_pool") val hasPool: Boolean? = null,
+ @SerialName("has_sprinkler_system") val hasSprinklerSystem: Boolean? = null,
+ @SerialName("has_septic") val hasSeptic: Boolean? = null,
+ @SerialName("has_fireplace") val hasFireplace: Boolean? = null,
+ @SerialName("has_garage") val hasGarage: Boolean? = null,
+ @SerialName("has_basement") val hasBasement: Boolean? = null,
+ @SerialName("has_attic") val hasAttic: Boolean? = null,
+ @SerialName("exterior_type") val exteriorType: String? = null,
+ @SerialName("flooring_primary") val flooringPrimary: String? = null,
+ @SerialName("landscaping_type") val landscapingType: String? = null
)
/**
@@ -118,7 +146,21 @@ data class ResidenceUpdateRequest(
val description: String? = null,
@SerialName("purchase_date") val purchaseDate: String? = null,
@SerialName("purchase_price") val purchasePrice: Double? = null,
- @SerialName("is_primary") val isPrimary: Boolean? = null
+ @SerialName("is_primary") val isPrimary: Boolean? = null,
+ @SerialName("heating_type") val heatingType: String? = null,
+ @SerialName("cooling_type") val coolingType: String? = null,
+ @SerialName("water_heater_type") val waterHeaterType: String? = null,
+ @SerialName("roof_type") val roofType: String? = null,
+ @SerialName("has_pool") val hasPool: Boolean? = null,
+ @SerialName("has_sprinkler_system") val hasSprinklerSystem: Boolean? = null,
+ @SerialName("has_septic") val hasSeptic: Boolean? = null,
+ @SerialName("has_fireplace") val hasFireplace: Boolean? = null,
+ @SerialName("has_garage") val hasGarage: Boolean? = null,
+ @SerialName("has_basement") val hasBasement: Boolean? = null,
+ @SerialName("has_attic") val hasAttic: Boolean? = null,
+ @SerialName("exterior_type") val exteriorType: String? = null,
+ @SerialName("flooring_primary") val flooringPrimary: String? = null,
+ @SerialName("landscaping_type") val landscapingType: String? = null
)
/**
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskSuggestion.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskSuggestion.kt
new file mode 100644
index 0000000..e50a3b0
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskSuggestion.kt
@@ -0,0 +1,24 @@
+package com.tt.honeyDue.models
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * A single task suggestion with relevance scoring from the backend.
+ */
+@Serializable
+data class TaskSuggestionResponse(
+ val template: TaskTemplate,
+ @SerialName("relevance_score") val relevanceScore: Double,
+ @SerialName("match_reasons") val matchReasons: List
+)
+
+/**
+ * Response wrapper for task suggestions endpoint.
+ */
+@Serializable
+data class TaskSuggestionsResponse(
+ val suggestions: List,
+ @SerialName("total_count") val totalCount: Int,
+ @SerialName("profile_completeness") val profileCompleteness: Double
+)
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
index 818e4eb..5221b9e 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
@@ -1209,6 +1209,14 @@ object APILayer {
return taskTemplateApi.getTemplatesByRegion(state = state, zip = zip)
}
+ /**
+ * Get personalized task suggestions for a residence based on its home profile.
+ */
+ suspend fun getTaskSuggestions(residenceId: Int): ApiResult {
+ val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
+ return taskTemplateApi.getTaskSuggestions(token, residenceId)
+ }
+
// ==================== Auth Operations ====================
suspend fun login(request: LoginRequest): ApiResult {
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskTemplateApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskTemplateApi.kt
index 1d4daf5..52eee88 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskTemplateApi.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskTemplateApi.kt
@@ -1,6 +1,7 @@
package com.tt.honeyDue.network
import com.tt.honeyDue.models.TaskTemplate
+import com.tt.honeyDue.models.TaskSuggestionsResponse
import com.tt.honeyDue.models.TaskTemplatesGroupedResponse
import io.ktor.client.*
import io.ktor.client.call.*
@@ -105,6 +106,27 @@ class TaskTemplateApi(private val client: HttpClient = ApiClient.httpClient) {
}
}
+ /**
+ * Get personalized task suggestions for a residence based on its home profile.
+ * Requires authentication.
+ */
+ suspend fun getTaskSuggestions(token: String, residenceId: Int): ApiResult {
+ return try {
+ val response = client.get("$baseUrl/tasks/suggestions/") {
+ header("Authorization", "Token $token")
+ parameter("residence_id", residenceId)
+ }
+
+ if (response.status.isSuccess()) {
+ ApiResult.Success(response.body())
+ } else {
+ ApiResult.Error("Failed to fetch task suggestions", 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/tt/honeyDue/ui/screens/onboarding/OnboardingFirstTaskContent.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingFirstTaskContent.kt
index 14cac0d..f44cdb7 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingFirstTaskContent.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingFirstTaskContent.kt
@@ -23,6 +23,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.models.TaskCreateRequest
+import com.tt.honeyDue.models.TaskSuggestionResponse
+import com.tt.honeyDue.models.TaskSuggestionsResponse
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.ui.theme.*
import com.tt.honeyDue.viewmodel.OnboardingViewModel
@@ -54,12 +56,22 @@ fun OnboardingFirstTaskContent(
viewModel: OnboardingViewModel,
onTasksAdded: () -> Unit
) {
- val maxTasksAllowed = 5
- var selectedTaskIds by remember { mutableStateOf(setOf()) }
+ var selectedBrowseIds by remember { mutableStateOf(setOf()) }
+ var selectedSuggestionIds by remember { mutableStateOf(setOf()) }
var expandedCategoryId by remember { mutableStateOf(null) }
var isCreatingTasks by remember { mutableStateOf(false) }
+ var selectedTabIndex by remember { mutableStateOf(0) }
val createTasksState by viewModel.createTasksState.collectAsState()
+ val suggestionsState by viewModel.suggestionsState.collectAsState()
+
+ // Load suggestions on mount if a residence exists
+ LaunchedEffect(Unit) {
+ val residence = DataManager.residences.value.firstOrNull()
+ if (residence != null) {
+ viewModel.loadSuggestions(residence.id)
+ }
+ }
LaunchedEffect(createTasksState) {
when (createTasksState) {
@@ -69,7 +81,6 @@ fun OnboardingFirstTaskContent(
}
is ApiResult.Error -> {
isCreatingTasks = false
- // Still proceed even if task creation fails
onTasksAdded()
}
is ApiResult.Loading -> {
@@ -148,159 +159,178 @@ fun OnboardingFirstTaskContent(
)
)
- val allTasks = taskCategories.flatMap { it.tasks }
- val selectedCount = selectedTaskIds.size
- val isAtMaxSelection = selectedCount >= maxTasksAllowed
+ val allBrowseTasks = taskCategories.flatMap { it.tasks }
+ val totalSelectedCount = selectedBrowseIds.size + selectedSuggestionIds.size
+ val isAtMaxSelection = false // No task selection limit
// Set first category expanded by default
LaunchedEffect(Unit) {
expandedCategoryId = taskCategories.firstOrNull()?.id
}
+ // Determine if suggestions are available
+ val hasSuggestions = suggestionsState is ApiResult.Success &&
+ (suggestionsState as? ApiResult.Success)?.data?.suggestions?.isNotEmpty() == true
+
Column(modifier = Modifier.fillMaxSize()) {
- LazyColumn(
+ // Header (shared across tabs)
+ Column(
modifier = Modifier
.fillMaxWidth()
- .weight(1f),
- contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
+ .padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md),
+ horizontalAlignment = Alignment.CenterHorizontally
) {
- // Header
- item {
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally
+ OrganicIconContainer(
+ icon = Icons.Default.Celebration,
+ size = 80.dp,
+ iconSize = 40.dp,
+ gradientColors = listOf(
+ MaterialTheme.colorScheme.primary,
+ MaterialTheme.colorScheme.secondary
+ ),
+ contentDescription = null
+ )
+
+ Spacer(modifier = Modifier.height(OrganicSpacing.lg))
+
+ Text(
+ text = stringResource(Res.string.onboarding_tasks_title),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+
+ Spacer(modifier = Modifier.height(OrganicSpacing.sm))
+
+ Text(
+ text = stringResource(Res.string.onboarding_tasks_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(OrganicSpacing.lg))
+
+ // Selection counter
+ Surface(
+ shape = RoundedCornerShape(OrganicRadius.xl),
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.sm),
+ horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
+ verticalAlignment = Alignment.CenterVertically
) {
- // Celebration icon using OrganicIconContainer
- OrganicIconContainer(
- icon = Icons.Default.Celebration,
- size = 80.dp,
- iconSize = 40.dp,
- gradientColors = listOf(
- MaterialTheme.colorScheme.primary,
- MaterialTheme.colorScheme.secondary
- ),
- contentDescription = null
+ Icon(
+ imageVector = Icons.Default.CheckCircleOutline,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary
)
-
- Spacer(modifier = Modifier.height(OrganicSpacing.lg))
-
Text(
- text = stringResource(Res.string.onboarding_tasks_title),
- style = MaterialTheme.typography.headlineMedium,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onBackground
- )
-
- Spacer(modifier = Modifier.height(OrganicSpacing.sm))
-
- Text(
- text = stringResource(Res.string.onboarding_tasks_subtitle),
+ text = "$totalSelectedCount task${if (totalSelectedCount == 1) "" else "s"} selected",
style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center
- )
-
- Spacer(modifier = Modifier.height(OrganicSpacing.lg))
-
- // Selection counter
- Surface(
- shape = RoundedCornerShape(OrganicRadius.xl),
- color = if (isAtMaxSelection) {
- MaterialTheme.colorScheme.tertiary.copy(alpha = 0.1f)
- } else {
- MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
- }
- ) {
- Row(
- modifier = Modifier.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.sm),
- horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(
- imageVector = if (isAtMaxSelection) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline,
- contentDescription = null,
- modifier = Modifier.size(20.dp),
- tint = if (isAtMaxSelection) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary
- )
- Text(
- text = "$selectedCount/$maxTasksAllowed tasks selected",
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = FontWeight.Medium,
- color = if (isAtMaxSelection) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary
- )
- }
- }
-
- Spacer(modifier = Modifier.height(OrganicSpacing.xl))
- }
- }
-
- // Task categories
- items(taskCategories) { category ->
- TaskCategorySection(
- category = category,
- selectedTaskIds = selectedTaskIds,
- isExpanded = expandedCategoryId == category.id,
- isAtMaxSelection = isAtMaxSelection,
- onToggleExpand = {
- expandedCategoryId = if (expandedCategoryId == category.id) null else category.id
- },
- onToggleTask = { taskId ->
- selectedTaskIds = if (taskId in selectedTaskIds) {
- selectedTaskIds - taskId
- } else if (!isAtMaxSelection) {
- selectedTaskIds + taskId
- } else {
- selectedTaskIds
- }
- }
- )
- Spacer(modifier = Modifier.height(OrganicSpacing.md))
- }
-
- // Add popular tasks button
- item {
- OutlinedButton(
- onClick = {
- val popularTitles = listOf(
- "Change HVAC Filter",
- "Test Smoke Detectors",
- "Check for Leaks",
- "Clean Gutters",
- "Clean Refrigerator Coils"
- )
- val popularIds = allTasks
- .filter { it.title in popularTitles }
- .take(maxTasksAllowed)
- .map { it.id }
- .toSet()
- selectedTaskIds = popularIds
- },
- modifier = Modifier
- .fillMaxWidth()
- .height(56.dp),
- shape = RoundedCornerShape(OrganicRadius.lg),
- border = ButtonDefaults.outlinedButtonBorder.copy(
- brush = Brush.linearGradient(
- colors = listOf(
- MaterialTheme.colorScheme.primary,
- MaterialTheme.colorScheme.tertiary
- )
- )
- )
- ) {
- Icon(Icons.Default.AutoAwesome, contentDescription = null)
- Spacer(modifier = Modifier.width(OrganicSpacing.sm))
- Text(
- text = stringResource(Res.string.onboarding_tasks_add_popular),
- fontWeight = FontWeight.Medium
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.primary
)
}
- Spacer(modifier = Modifier.height(100.dp)) // Space for bottom button
}
}
- // Bottom action area
+ // Tab row (only show if we have suggestions)
+ if (hasSuggestions || suggestionsState is ApiResult.Loading) {
+ TabRow(
+ selectedTabIndex = selectedTabIndex,
+ containerColor = MaterialTheme.colorScheme.surface,
+ contentColor = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Tab(
+ selected = selectedTabIndex == 0,
+ onClick = { selectedTabIndex = 0 },
+ text = {
+ Text(
+ text = stringResource(Res.string.for_you_tab),
+ fontWeight = if (selectedTabIndex == 0) FontWeight.SemiBold else FontWeight.Normal
+ )
+ },
+ icon = {
+ Icon(
+ Icons.Default.AutoAwesome,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ )
+ Tab(
+ selected = selectedTabIndex == 1,
+ onClick = { selectedTabIndex = 1 },
+ text = {
+ Text(
+ text = stringResource(Res.string.browse_tab),
+ fontWeight = if (selectedTabIndex == 1) FontWeight.SemiBold else FontWeight.Normal
+ )
+ },
+ icon = {
+ Icon(
+ Icons.Default.ViewList,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ )
+ }
+ }
+
+ // Tab content
+ when {
+ (hasSuggestions || suggestionsState is ApiResult.Loading) && selectedTabIndex == 0 -> {
+ // For You tab
+ ForYouTabContent(
+ suggestionsState = suggestionsState,
+ selectedSuggestionIds = selectedSuggestionIds,
+ isAtMaxSelection = isAtMaxSelection,
+ onToggleSuggestion = { templateId ->
+ selectedSuggestionIds = if (templateId in selectedSuggestionIds) {
+ selectedSuggestionIds - templateId
+ } else if (!isAtMaxSelection) {
+ selectedSuggestionIds + templateId
+ } else {
+ selectedSuggestionIds
+ }
+ },
+ modifier = Modifier.weight(1f)
+ )
+ }
+ else -> {
+ // Browse tab (or default when no suggestions)
+ BrowseTabContent(
+ taskCategories = taskCategories,
+ allTasks = allBrowseTasks,
+ selectedTaskIds = selectedBrowseIds,
+ expandedCategoryId = expandedCategoryId,
+ isAtMaxSelection = isAtMaxSelection,
+ onToggleExpand = { catId ->
+ expandedCategoryId = if (expandedCategoryId == catId) null else catId
+ },
+ onToggleTask = { taskId ->
+ selectedBrowseIds = if (taskId in selectedBrowseIds) {
+ selectedBrowseIds - taskId
+ } else if (!isAtMaxSelection) {
+ selectedBrowseIds + taskId
+ } else {
+ selectedBrowseIds
+ }
+ },
+ onAddPopular = { popularIds ->
+ selectedBrowseIds = popularIds
+ },
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+
+ // Bottom action area (shared)
Surface(
modifier = Modifier.fillMaxWidth(),
shadowElevation = 8.dp
@@ -309,30 +339,30 @@ fun OnboardingFirstTaskContent(
modifier = Modifier.padding(OrganicSpacing.lg)
) {
OrganicPrimaryButton(
- text = if (selectedCount > 0) {
- "Add $selectedCount Task${if (selectedCount == 1) "" else "s"} & Continue"
+ text = if (totalSelectedCount > 0) {
+ "Add $totalSelectedCount Task${if (totalSelectedCount == 1) "" else "s"} & Continue"
} else {
stringResource(Res.string.onboarding_tasks_skip)
},
onClick = {
- if (selectedTaskIds.isEmpty()) {
+ if (selectedBrowseIds.isEmpty() && selectedSuggestionIds.isEmpty()) {
onTasksAdded()
} else {
val residences = DataManager.residences.value
val residence = residences.firstOrNull()
if (residence != null) {
val today = DateUtils.getTodayString()
+ val taskRequests = mutableListOf()
- val selectedTemplates = allTasks.filter { it.id in selectedTaskIds }
- val taskRequests = selectedTemplates.map { template ->
+ // Browse tab selections
+ val selectedBrowseTemplates = allBrowseTasks.filter { it.id in selectedBrowseIds }
+ taskRequests.addAll(selectedBrowseTemplates.map { template ->
val categoryId = DataManager.taskCategories.value
.find { cat -> cat.name.lowercase() == template.category.lowercase() }
?.id
-
val frequencyId = DataManager.taskFrequencies.value
.find { freq -> freq.name.lowercase() == template.frequency.lowercase() }
?.id
-
TaskCreateRequest(
residenceId = residence.id,
title = template.title,
@@ -346,7 +376,29 @@ fun OnboardingFirstTaskContent(
estimatedCost = null,
contractorId = null
)
+ })
+
+ // For You tab selections
+ val suggestions = (suggestionsState as? ApiResult.Success)?.data?.suggestions
+ suggestions?.filter { it.template.id in selectedSuggestionIds }?.forEach { suggestion ->
+ val tmpl = suggestion.template
+ taskRequests.add(
+ TaskCreateRequest(
+ residenceId = residence.id,
+ title = tmpl.title,
+ description = tmpl.description.takeIf { it.isNotBlank() },
+ categoryId = tmpl.categoryId,
+ priorityId = null,
+ inProgress = false,
+ frequencyId = tmpl.frequencyId,
+ assignedToId = null,
+ dueDate = today,
+ estimatedCost = null,
+ contractorId = null
+ )
+ )
}
+
viewModel.createTasks(taskRequests)
} else {
onTasksAdded()
@@ -363,6 +415,237 @@ fun OnboardingFirstTaskContent(
}
}
+// ==================== For You Tab ====================
+
+@Composable
+private fun ForYouTabContent(
+ suggestionsState: ApiResult,
+ selectedSuggestionIds: Set,
+ isAtMaxSelection: Boolean,
+ onToggleSuggestion: (Int) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ when (suggestionsState) {
+ is ApiResult.Loading -> {
+ Box(
+ modifier = modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
+ Spacer(modifier = Modifier.height(OrganicSpacing.md))
+ Text(
+ text = "Finding tasks for your home...",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ is ApiResult.Success -> {
+ val suggestions = suggestionsState.data.suggestions
+ LazyColumn(
+ modifier = modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
+ ) {
+ items(suggestions) { suggestion ->
+ SuggestionRow(
+ suggestion = suggestion,
+ isSelected = suggestion.template.id in selectedSuggestionIds,
+ isDisabled = isAtMaxSelection && suggestion.template.id !in selectedSuggestionIds,
+ onToggle = { onToggleSuggestion(suggestion.template.id) }
+ )
+ Spacer(modifier = Modifier.height(OrganicSpacing.sm))
+ }
+ item {
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ }
+ }
+ is ApiResult.Error -> {
+ Box(
+ modifier = modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "Could not load suggestions. Try the Browse tab.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ else -> {}
+ }
+}
+
+@Composable
+private fun SuggestionRow(
+ suggestion: TaskSuggestionResponse,
+ isSelected: Boolean,
+ isDisabled: Boolean,
+ onToggle: () -> Unit
+) {
+ val template = suggestion.template
+ val relevancePercent = (suggestion.relevanceScore * 100).toInt()
+
+ OrganicCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(enabled = !isDisabled) { onToggle() },
+ accentColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
+ showBlob = false
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(OrganicSpacing.md),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Checkbox
+ Box(
+ modifier = Modifier
+ .size(28.dp)
+ .clip(CircleShape)
+ .background(
+ if (isSelected) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.outline.copy(alpha = if (isDisabled) 0.15f else 0.3f)
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ if (isSelected) {
+ Icon(
+ Icons.Default.Check,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(OrganicSpacing.md))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = template.title,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = if (isDisabled) {
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ }
+ )
+ Text(
+ text = template.frequencyDisplay,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
+ alpha = if (isDisabled) 0.5f else 1f
+ )
+ )
+ if (suggestion.matchReasons.isNotEmpty()) {
+ Text(
+ text = suggestion.matchReasons.first(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
+ )
+ }
+ }
+
+ // Relevance indicator
+ Surface(
+ shape = RoundedCornerShape(OrganicRadius.lg),
+ color = MaterialTheme.colorScheme.primary.copy(
+ alpha = (suggestion.relevanceScore * 0.2f).toFloat().coerceIn(0.05f, 0.2f)
+ )
+ ) {
+ Text(
+ text = "$relevancePercent%",
+ modifier = Modifier.padding(horizontal = OrganicSpacing.sm, vertical = OrganicSpacing.xs),
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+}
+
+// ==================== Browse Tab ====================
+
+@Composable
+private fun BrowseTabContent(
+ taskCategories: List,
+ allTasks: List,
+ selectedTaskIds: Set,
+ expandedCategoryId: String?,
+ isAtMaxSelection: Boolean,
+ onToggleExpand: (String) -> Unit,
+ onToggleTask: (String) -> Unit,
+ onAddPopular: (Set) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ LazyColumn(
+ modifier = modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
+ ) {
+ // Task categories
+ items(taskCategories) { category ->
+ TaskCategorySection(
+ category = category,
+ selectedTaskIds = selectedTaskIds,
+ isExpanded = expandedCategoryId == category.id,
+ isAtMaxSelection = isAtMaxSelection,
+ onToggleExpand = { onToggleExpand(category.id) },
+ onToggleTask = onToggleTask
+ )
+ Spacer(modifier = Modifier.height(OrganicSpacing.md))
+ }
+
+ // Add popular tasks button
+ item {
+ OutlinedButton(
+ onClick = {
+ val popularTitles = listOf(
+ "Change HVAC Filter",
+ "Test Smoke Detectors",
+ "Check for Leaks",
+ "Clean Gutters",
+ "Clean Refrigerator Coils"
+ )
+ val popularIds = allTasks
+ .filter { it.title in popularTitles }
+ .map { it.id }
+ .toSet()
+ onAddPopular(popularIds)
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(OrganicRadius.lg),
+ border = ButtonDefaults.outlinedButtonBorder.copy(
+ brush = Brush.linearGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.primary,
+ MaterialTheme.colorScheme.tertiary
+ )
+ )
+ )
+ ) {
+ Icon(Icons.Default.AutoAwesome, contentDescription = null)
+ Spacer(modifier = Modifier.width(OrganicSpacing.sm))
+ Text(
+ text = stringResource(Res.string.onboarding_tasks_add_popular),
+ fontWeight = FontWeight.Medium
+ )
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ }
+}
+
+// ==================== Category / Row Components ====================
+
@Composable
private fun TaskCategorySection(
category: OnboardingTaskCategory,
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingHomeProfileContent.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingHomeProfileContent.kt
new file mode 100644
index 0000000..a841980
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingHomeProfileContent.kt
@@ -0,0 +1,378 @@
+package com.tt.honeyDue.ui.screens.onboarding
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+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.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.tt.honeyDue.models.HomeProfileOptions
+import com.tt.honeyDue.ui.theme.*
+import com.tt.honeyDue.viewmodel.OnboardingViewModel
+import honeydue.composeapp.generated.resources.*
+import org.jetbrains.compose.resources.stringResource
+
+@Composable
+fun OnboardingHomeProfileContent(
+ viewModel: OnboardingViewModel,
+ onContinue: () -> Unit,
+ onSkip: () -> Unit
+) {
+ val heatingType by viewModel.heatingType.collectAsState()
+ val coolingType by viewModel.coolingType.collectAsState()
+ val waterHeaterType by viewModel.waterHeaterType.collectAsState()
+ val roofType by viewModel.roofType.collectAsState()
+ val hasPool by viewModel.hasPool.collectAsState()
+ val hasSprinklerSystem by viewModel.hasSprinklerSystem.collectAsState()
+ val hasSeptic by viewModel.hasSeptic.collectAsState()
+ val hasFireplace by viewModel.hasFireplace.collectAsState()
+ val hasGarage by viewModel.hasGarage.collectAsState()
+ val hasBasement by viewModel.hasBasement.collectAsState()
+ val hasAttic by viewModel.hasAttic.collectAsState()
+ val exteriorType by viewModel.exteriorType.collectAsState()
+ val flooringPrimary by viewModel.flooringPrimary.collectAsState()
+ val landscapingType by viewModel.landscapingType.collectAsState()
+
+ Column(modifier = Modifier.fillMaxSize()) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
+ ) {
+ // Header
+ item {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ OrganicIconContainer(
+ icon = Icons.Default.Tune,
+ size = 80.dp,
+ iconSize = 40.dp,
+ gradientColors = listOf(
+ MaterialTheme.colorScheme.primary,
+ MaterialTheme.colorScheme.tertiary
+ ),
+ contentDescription = null
+ )
+
+ Spacer(modifier = Modifier.height(OrganicSpacing.lg))
+
+ Text(
+ text = stringResource(Res.string.onboarding_home_profile_title),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+
+ Spacer(modifier = Modifier.height(OrganicSpacing.sm))
+
+ Text(
+ text = stringResource(Res.string.onboarding_home_profile_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(OrganicSpacing.xl))
+ }
+ }
+
+ // Systems section
+ item {
+ ProfileSectionHeader(
+ icon = Icons.Default.Settings,
+ title = stringResource(Res.string.onboarding_home_profile_systems)
+ )
+ Spacer(modifier = Modifier.height(OrganicSpacing.sm))
+ }
+
+ item {
+ OptionDropdownChips(
+ label = "Heating",
+ options = HomeProfileOptions.heatingTypes,
+ selectedValue = heatingType,
+ onSelect = { viewModel.setHeatingType(it) }
+ )
+ Spacer(modifier = Modifier.height(OrganicSpacing.md))
+ }
+
+ item {
+ OptionDropdownChips(
+ label = "Cooling",
+ options = HomeProfileOptions.coolingTypes,
+ selectedValue = coolingType,
+ onSelect = { viewModel.setCoolingType(it) }
+ )
+ Spacer(modifier = Modifier.height(OrganicSpacing.md))
+ }
+
+ item {
+ OptionDropdownChips(
+ label = "Water Heater",
+ options = HomeProfileOptions.waterHeaterTypes,
+ selectedValue = waterHeaterType,
+ onSelect = { viewModel.setWaterHeaterType(it) }
+ )
+ Spacer(modifier = Modifier.height(OrganicSpacing.xl))
+ }
+
+ // Features section
+ item {
+ ProfileSectionHeader(
+ icon = Icons.Default.Star,
+ title = stringResource(Res.string.onboarding_home_profile_features)
+ )
+ Spacer(modifier = Modifier.height(OrganicSpacing.sm))
+ }
+
+ item {
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
+ verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
+ ) {
+ ToggleChip(label = "Pool", selected = hasPool, onToggle = { viewModel.setHasPool(!hasPool) })
+ ToggleChip(label = "Sprinkler System", selected = hasSprinklerSystem, onToggle = { viewModel.setHasSprinklerSystem(!hasSprinklerSystem) })
+ ToggleChip(label = "Fireplace", selected = hasFireplace, onToggle = { viewModel.setHasFireplace(!hasFireplace) })
+ ToggleChip(label = "Garage", selected = hasGarage, onToggle = { viewModel.setHasGarage(!hasGarage) })
+ ToggleChip(label = "Basement", selected = hasBasement, onToggle = { viewModel.setHasBasement(!hasBasement) })
+ ToggleChip(label = "Attic", selected = hasAttic, onToggle = { viewModel.setHasAttic(!hasAttic) })
+ ToggleChip(label = "Septic", selected = hasSeptic, onToggle = { viewModel.setHasSeptic(!hasSeptic) })
+ }
+ Spacer(modifier = Modifier.height(OrganicSpacing.xl))
+ }
+
+ // Exterior section
+ item {
+ ProfileSectionHeader(
+ icon = Icons.Default.Roofing,
+ title = stringResource(Res.string.onboarding_home_profile_exterior)
+ )
+ Spacer(modifier = Modifier.height(OrganicSpacing.sm))
+ }
+
+ item {
+ OptionDropdownChips(
+ label = "Roof Type",
+ options = HomeProfileOptions.roofTypes,
+ selectedValue = roofType,
+ onSelect = { viewModel.setRoofType(it) }
+ )
+ Spacer(modifier = Modifier.height(OrganicSpacing.md))
+ }
+
+ item {
+ OptionDropdownChips(
+ label = "Exterior",
+ options = HomeProfileOptions.exteriorTypes,
+ selectedValue = exteriorType,
+ onSelect = { viewModel.setExteriorType(it) }
+ )
+ Spacer(modifier = Modifier.height(OrganicSpacing.xl))
+ }
+
+ // Interior section
+ item {
+ ProfileSectionHeader(
+ icon = Icons.Default.Weekend,
+ title = stringResource(Res.string.onboarding_home_profile_interior)
+ )
+ Spacer(modifier = Modifier.height(OrganicSpacing.sm))
+ }
+
+ item {
+ OptionDropdownChips(
+ label = "Flooring",
+ options = HomeProfileOptions.flooringTypes,
+ selectedValue = flooringPrimary,
+ onSelect = { viewModel.setFlooringPrimary(it) }
+ )
+ Spacer(modifier = Modifier.height(OrganicSpacing.md))
+ }
+
+ item {
+ OptionDropdownChips(
+ label = "Landscaping",
+ options = HomeProfileOptions.landscapingTypes,
+ selectedValue = landscapingType,
+ onSelect = { viewModel.setLandscapingType(it) }
+ )
+ Spacer(modifier = Modifier.height(100.dp)) // Space for bottom button
+ }
+ }
+
+ // Bottom action area
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shadowElevation = 8.dp
+ ) {
+ Column(
+ modifier = Modifier.padding(OrganicSpacing.lg)
+ ) {
+ OrganicPrimaryButton(
+ text = stringResource(Res.string.onboarding_continue),
+ onClick = onContinue,
+ modifier = Modifier.fillMaxWidth(),
+ icon = Icons.Default.ArrowForward
+ )
+
+ Spacer(modifier = Modifier.height(OrganicSpacing.sm))
+
+ TextButton(
+ onClick = onSkip,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = stringResource(Res.string.onboarding_skip),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ProfileSectionHeader(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ title: String
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ }
+}
+
+@Composable
+private fun OptionDropdownChips(
+ label: String,
+ options: List>,
+ selectedValue: String?,
+ onSelect: (String?) -> Unit
+) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(OrganicSpacing.xs))
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.xs),
+ verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
+ ) {
+ options.forEach { (apiValue, displayLabel) ->
+ val isSelected = selectedValue == apiValue
+ FilterChip(
+ selected = isSelected,
+ onClick = {
+ onSelect(if (isSelected) null else apiValue)
+ },
+ label = {
+ Text(
+ text = displayLabel,
+ style = MaterialTheme.typography.bodySmall
+ )
+ },
+ shape = RoundedCornerShape(OrganicRadius.lg),
+ colors = FilterChipDefaults.filterChipColors(
+ selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
+ selectedLabelColor = MaterialTheme.colorScheme.primary
+ ),
+ border = BorderStroke(
+ width = 1.dp,
+ color = if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
+ else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
+ )
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ToggleChip(
+ label: String,
+ selected: Boolean,
+ onToggle: () -> Unit
+) {
+ val containerColor by animateColorAsState(
+ targetValue = if (selected) {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
+ },
+ label = "toggleChipColor"
+ )
+ val contentColor by animateColorAsState(
+ targetValue = if (selected) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ label = "toggleChipContentColor"
+ )
+
+ FilterChip(
+ selected = selected,
+ onClick = onToggle,
+ label = {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal
+ )
+ },
+ leadingIcon = if (selected) {
+ {
+ Icon(
+ Icons.Default.Check,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ } else null,
+ shape = RoundedCornerShape(OrganicRadius.xl),
+ colors = FilterChipDefaults.filterChipColors(
+ containerColor = containerColor,
+ labelColor = contentColor,
+ iconColor = contentColor,
+ selectedContainerColor = containerColor,
+ selectedLabelColor = contentColor,
+ selectedLeadingIconColor = contentColor
+ ),
+ border = FilterChipDefaults.filterChipBorder(
+ borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
+ selectedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
+ enabled = true,
+ selected = selected
+ )
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingScreen.kt
index 3870327..3bec037 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingScreen.kt
@@ -126,7 +126,7 @@ fun OnboardingScreen(
OnboardingStep.JOIN_RESIDENCE -> OnboardingJoinResidenceContent(
viewModel = viewModel,
- onJoined = { viewModel.nextStep() }
+ onJoined = { viewModel.completeOnboarding() }
)
OnboardingStep.RESIDENCE_LOCATION -> OnboardingLocationContent(
@@ -138,15 +138,21 @@ fun OnboardingScreen(
onSkip = { viewModel.nextStep() }
)
- OnboardingStep.FIRST_TASK -> OnboardingFirstTaskContent(
+ OnboardingStep.HOME_PROFILE -> OnboardingHomeProfileContent(
viewModel = viewModel,
- onTasksAdded = { viewModel.nextStep() }
+ onContinue = { viewModel.nextStep() },
+ onSkip = { viewModel.skipStep() }
)
- OnboardingStep.SUBSCRIPTION_UPSELL -> OnboardingSubscriptionContent(
- onSubscribe = { viewModel.completeOnboarding() },
- onSkip = { viewModel.completeOnboarding() }
+ OnboardingStep.FIRST_TASK -> OnboardingFirstTaskContent(
+ viewModel = viewModel,
+ onTasksAdded = { viewModel.completeOnboarding() }
)
+
+ OnboardingStep.SUBSCRIPTION_UPSELL -> {
+ // Subscription removed from onboarding — app is free
+ LaunchedEffect(Unit) { viewModel.completeOnboarding() }
+ }
}
}
}
@@ -164,6 +170,7 @@ private fun OnboardingNavigationBar(
OnboardingStep.WELCOME,
OnboardingStep.JOIN_RESIDENCE,
OnboardingStep.RESIDENCE_LOCATION,
+ OnboardingStep.HOME_PROFILE,
OnboardingStep.FIRST_TASK,
OnboardingStep.SUBSCRIPTION_UPSELL -> false
else -> true
@@ -173,6 +180,7 @@ private fun OnboardingNavigationBar(
OnboardingStep.VALUE_PROPS,
OnboardingStep.JOIN_RESIDENCE,
OnboardingStep.RESIDENCE_LOCATION,
+ OnboardingStep.HOME_PROFILE,
OnboardingStep.FIRST_TASK,
OnboardingStep.SUBSCRIPTION_UPSELL -> true
else -> false
@@ -182,6 +190,7 @@ private fun OnboardingNavigationBar(
OnboardingStep.WELCOME,
OnboardingStep.JOIN_RESIDENCE,
OnboardingStep.RESIDENCE_LOCATION,
+ OnboardingStep.HOME_PROFILE,
OnboardingStep.FIRST_TASK,
OnboardingStep.SUBSCRIPTION_UPSELL -> false
else -> true
@@ -195,6 +204,7 @@ private fun OnboardingNavigationBar(
OnboardingStep.VERIFY_EMAIL -> 4
OnboardingStep.JOIN_RESIDENCE -> 4
OnboardingStep.RESIDENCE_LOCATION -> 4
+ OnboardingStep.HOME_PROFILE -> 4
OnboardingStep.FIRST_TASK -> 4
OnboardingStep.SUBSCRIPTION_UPSELL -> 4
}
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/OnboardingViewModel.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/OnboardingViewModel.kt
index 28a8ebe..373a5b4 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/OnboardingViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/OnboardingViewModel.kt
@@ -8,6 +8,7 @@ import com.tt.honeyDue.models.LoginRequest
import com.tt.honeyDue.models.RegisterRequest
import com.tt.honeyDue.models.ResidenceCreateRequest
import com.tt.honeyDue.models.TaskCreateRequest
+import com.tt.honeyDue.models.TaskSuggestionsResponse
import com.tt.honeyDue.models.TaskTemplate
import com.tt.honeyDue.models.VerifyEmailRequest
import com.tt.honeyDue.network.ApiResult
@@ -37,6 +38,7 @@ enum class OnboardingStep {
VERIFY_EMAIL,
JOIN_RESIDENCE,
RESIDENCE_LOCATION,
+ HOME_PROFILE,
FIRST_TASK,
SUBSCRIPTION_UPSELL
}
@@ -90,6 +92,53 @@ class OnboardingViewModel : ViewModel() {
private val _postalCode = MutableStateFlow("")
val postalCode: StateFlow = _postalCode
+ // Home profile fields
+ private val _heatingType = MutableStateFlow(null)
+ val heatingType: StateFlow = _heatingType
+
+ private val _coolingType = MutableStateFlow(null)
+ val coolingType: StateFlow = _coolingType
+
+ private val _waterHeaterType = MutableStateFlow(null)
+ val waterHeaterType: StateFlow = _waterHeaterType
+
+ private val _roofType = MutableStateFlow(null)
+ val roofType: StateFlow = _roofType
+
+ private val _hasPool = MutableStateFlow(false)
+ val hasPool: StateFlow = _hasPool
+
+ private val _hasSprinklerSystem = MutableStateFlow(false)
+ val hasSprinklerSystem: StateFlow = _hasSprinklerSystem
+
+ private val _hasSeptic = MutableStateFlow(false)
+ val hasSeptic: StateFlow = _hasSeptic
+
+ private val _hasFireplace = MutableStateFlow(false)
+ val hasFireplace: StateFlow = _hasFireplace
+
+ private val _hasGarage = MutableStateFlow(false)
+ val hasGarage: StateFlow = _hasGarage
+
+ private val _hasBasement = MutableStateFlow(false)
+ val hasBasement: StateFlow = _hasBasement
+
+ private val _hasAttic = MutableStateFlow(false)
+ val hasAttic: StateFlow = _hasAttic
+
+ private val _exteriorType = MutableStateFlow(null)
+ val exteriorType: StateFlow = _exteriorType
+
+ private val _flooringPrimary = MutableStateFlow(null)
+ val flooringPrimary: StateFlow = _flooringPrimary
+
+ private val _landscapingType = MutableStateFlow(null)
+ val landscapingType: StateFlow = _landscapingType
+
+ // Task suggestions state
+ private val _suggestionsState = MutableStateFlow>(ApiResult.Idle)
+ val suggestionsState: StateFlow> = _suggestionsState
+
// Whether onboarding is complete
private val _isComplete = MutableStateFlow(false)
val isComplete: StateFlow = _isComplete
@@ -106,6 +155,32 @@ class OnboardingViewModel : ViewModel() {
_shareCode.value = code
}
+ // Home profile setters
+ fun setHeatingType(value: String?) { _heatingType.value = value }
+ fun setCoolingType(value: String?) { _coolingType.value = value }
+ fun setWaterHeaterType(value: String?) { _waterHeaterType.value = value }
+ fun setRoofType(value: String?) { _roofType.value = value }
+ fun setHasPool(value: Boolean) { _hasPool.value = value }
+ fun setHasSprinklerSystem(value: Boolean) { _hasSprinklerSystem.value = value }
+ fun setHasSeptic(value: Boolean) { _hasSeptic.value = value }
+ fun setHasFireplace(value: Boolean) { _hasFireplace.value = value }
+ fun setHasGarage(value: Boolean) { _hasGarage.value = value }
+ fun setHasBasement(value: Boolean) { _hasBasement.value = value }
+ fun setHasAttic(value: Boolean) { _hasAttic.value = value }
+ fun setExteriorType(value: String?) { _exteriorType.value = value }
+ fun setFlooringPrimary(value: String?) { _flooringPrimary.value = value }
+ fun setLandscapingType(value: String?) { _landscapingType.value = value }
+
+ /**
+ * Load personalized task suggestions for the given residence.
+ */
+ fun loadSuggestions(residenceId: Int) {
+ viewModelScope.launch {
+ _suggestionsState.value = ApiResult.Loading
+ _suggestionsState.value = APILayer.getTaskSuggestions(residenceId)
+ }
+ }
+
/**
* Move to the next step in the flow
* Flow: Welcome → Features → Name Residence → Create Account → Verify → Tasks → Upsell
@@ -129,9 +204,16 @@ class OnboardingViewModel : ViewModel() {
OnboardingStep.RESIDENCE_LOCATION
}
}
- OnboardingStep.JOIN_RESIDENCE -> OnboardingStep.SUBSCRIPTION_UPSELL
- OnboardingStep.RESIDENCE_LOCATION -> OnboardingStep.FIRST_TASK
- OnboardingStep.FIRST_TASK -> OnboardingStep.SUBSCRIPTION_UPSELL
+ OnboardingStep.JOIN_RESIDENCE -> {
+ completeOnboarding()
+ OnboardingStep.JOIN_RESIDENCE
+ }
+ OnboardingStep.RESIDENCE_LOCATION -> OnboardingStep.HOME_PROFILE
+ OnboardingStep.HOME_PROFILE -> OnboardingStep.FIRST_TASK
+ OnboardingStep.FIRST_TASK -> {
+ completeOnboarding()
+ OnboardingStep.FIRST_TASK
+ }
OnboardingStep.SUBSCRIPTION_UPSELL -> {
completeOnboarding()
OnboardingStep.SUBSCRIPTION_UPSELL
@@ -171,9 +253,10 @@ class OnboardingViewModel : ViewModel() {
fun skipStep() {
when (_currentStep.value) {
OnboardingStep.VALUE_PROPS,
- OnboardingStep.JOIN_RESIDENCE,
OnboardingStep.RESIDENCE_LOCATION,
- OnboardingStep.FIRST_TASK -> nextStep()
+ OnboardingStep.HOME_PROFILE -> nextStep()
+ OnboardingStep.JOIN_RESIDENCE,
+ OnboardingStep.FIRST_TASK,
OnboardingStep.SUBSCRIPTION_UPSELL -> completeOnboarding()
else -> {}
}
@@ -272,7 +355,21 @@ class OnboardingViewModel : ViewModel() {
description = null,
purchaseDate = null,
purchasePrice = null,
- isPrimary = true
+ isPrimary = true,
+ heatingType = _heatingType.value,
+ coolingType = _coolingType.value,
+ waterHeaterType = _waterHeaterType.value,
+ roofType = _roofType.value,
+ hasPool = _hasPool.value.takeIf { it },
+ hasSprinklerSystem = _hasSprinklerSystem.value.takeIf { it },
+ hasSeptic = _hasSeptic.value.takeIf { it },
+ hasFireplace = _hasFireplace.value.takeIf { it },
+ hasGarage = _hasGarage.value.takeIf { it },
+ hasBasement = _hasBasement.value.takeIf { it },
+ hasAttic = _hasAttic.value.takeIf { it },
+ exteriorType = _exteriorType.value,
+ flooringPrimary = _flooringPrimary.value,
+ landscapingType = _landscapingType.value
)
)
@@ -362,6 +459,21 @@ class OnboardingViewModel : ViewModel() {
_createTasksState.value = ApiResult.Idle
_regionalTemplates.value = ApiResult.Idle
_postalCode.value = ""
+ _heatingType.value = null
+ _coolingType.value = null
+ _waterHeaterType.value = null
+ _roofType.value = null
+ _hasPool.value = false
+ _hasSprinklerSystem.value = false
+ _hasSeptic.value = false
+ _hasFireplace.value = false
+ _hasGarage.value = false
+ _hasBasement.value = false
+ _hasAttic.value = false
+ _exteriorType.value = null
+ _flooringPrimary.value = null
+ _landscapingType.value = null
+ _suggestionsState.value = ApiResult.Idle
_isComplete.value = false
}
diff --git a/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift b/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift
index dc28be5..1a85d41 100644
--- a/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift
+++ b/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift
@@ -69,6 +69,15 @@ struct OnboardingCoordinator: View {
isCreatingResidence = true
+ // Collect home profile booleans — only send true values
+ let hasPool = onboardingState.pendingHasPool ? KotlinBoolean(bool: true) : nil
+ let hasSprinkler = onboardingState.pendingHasSprinklerSystem ? KotlinBoolean(bool: true) : nil
+ let hasSeptic = onboardingState.pendingHasSeptic ? KotlinBoolean(bool: true) : nil
+ let hasFireplace = onboardingState.pendingHasFireplace ? KotlinBoolean(bool: true) : nil
+ let hasGarage = onboardingState.pendingHasGarage ? KotlinBoolean(bool: true) : nil
+ let hasBasement = onboardingState.pendingHasBasement ? KotlinBoolean(bool: true) : nil
+ let hasAttic = onboardingState.pendingHasAttic ? KotlinBoolean(bool: true) : nil
+
let request = ResidenceCreateRequest(
name: onboardingState.pendingResidenceName,
propertyTypeId: nil,
@@ -86,7 +95,21 @@ struct OnboardingCoordinator: View {
description: nil,
purchaseDate: nil,
purchasePrice: nil,
- isPrimary: KotlinBoolean(bool: true)
+ isPrimary: KotlinBoolean(bool: true),
+ heatingType: onboardingState.pendingHeatingType,
+ coolingType: onboardingState.pendingCoolingType,
+ waterHeaterType: onboardingState.pendingWaterHeaterType,
+ roofType: onboardingState.pendingRoofType,
+ hasPool: hasPool,
+ hasSprinklerSystem: hasSprinkler,
+ hasSeptic: hasSeptic,
+ hasFireplace: hasFireplace,
+ hasGarage: hasGarage,
+ hasBasement: hasBasement,
+ hasAttic: hasAttic,
+ exteriorType: onboardingState.pendingExteriorType,
+ flooringPrimary: onboardingState.pendingFlooringPrimary,
+ landscapingType: onboardingState.pendingLandscapingType
)
residenceViewModel.createResidence(request: request) { (residence: ResidenceResponse?) in
@@ -103,7 +126,7 @@ struct OnboardingCoordinator: View {
}
/// Current step index for progress indicator (0-based)
- /// Flow: Welcome → Features → Name → Account → Verify → Location → Tasks → Upsell
+ /// Flow: Welcome → Features → Name → Account → Verify → Location → Home Profile → Tasks → Upsell
private var currentProgressStep: Int {
switch onboardingState.currentStep {
case .welcome: return 0
@@ -113,6 +136,7 @@ struct OnboardingCoordinator: View {
case .verifyEmail: return 4
case .joinResidence: return 4
case .residenceLocation: return 4
+ case .homeProfile: return 4
case .firstTask: return 4
case .subscriptionUpsell: return 4
}
@@ -121,7 +145,7 @@ struct OnboardingCoordinator: View {
/// Whether to show the back button
private var showBackButton: Bool {
switch onboardingState.currentStep {
- case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
+ case .welcome, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
return false
default:
return true
@@ -131,7 +155,7 @@ struct OnboardingCoordinator: View {
/// Whether to show the skip button
private var showSkipButton: Bool {
switch onboardingState.currentStep {
- case .valueProps, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
+ case .valueProps, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
return true
default:
return false
@@ -141,7 +165,7 @@ struct OnboardingCoordinator: View {
/// Whether to show the progress indicator
private var showProgressIndicator: Bool {
switch onboardingState.currentStep {
- case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
+ case .welcome, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
return false
default:
return true
@@ -174,11 +198,15 @@ struct OnboardingCoordinator: View {
case .valueProps:
goForward()
case .residenceLocation:
- // Skipping location — still need to create residence (without postal code)
+ // Skipping location — go to home profile
+ goForward(to: .homeProfile)
+ case .homeProfile:
+ // Skipping home profile — create residence without profile data, go to tasks
createResidenceIfNeeded(thenNavigateTo: .firstTask)
- case .joinResidence, .firstTask:
- goForward()
- case .subscriptionUpsell:
+ case .joinResidence:
+ onboardingState.completeOnboarding()
+ onComplete()
+ case .firstTask, .subscriptionUpsell:
onboardingState.completeOnboarding()
onComplete()
default:
@@ -301,7 +329,8 @@ struct OnboardingCoordinator: View {
case .joinResidence:
OnboardingJoinResidenceContent(
onJoined: {
- goForward()
+ onboardingState.completeOnboarding()
+ onComplete()
}
)
.transition(navigationTransition)
@@ -309,9 +338,21 @@ struct OnboardingCoordinator: View {
case .residenceLocation:
OnboardingLocationContent(
onLocationDetected: { zip in
- // Load regional templates in background while creating residence
+ // Load regional templates in background
onboardingState.loadRegionalTemplates(zip: zip)
- // Create residence with postal code, then go to first task
+ // Go to home profile step (residence created after profile)
+ goForward()
+ },
+ onSkip: {
+ // Handled by handleSkip() above
+ }
+ )
+ .transition(navigationTransition)
+
+ case .homeProfile:
+ OnboardingHomeProfileContent(
+ onContinue: {
+ // Create residence with all collected data, then go to tasks
createResidenceIfNeeded(thenNavigateTo: .firstTask)
},
onSkip: {
@@ -324,20 +365,21 @@ struct OnboardingCoordinator: View {
OnboardingFirstTaskContent(
residenceName: onboardingState.pendingResidenceName,
onTaskAdded: {
- goForward()
- }
- )
- .transition(navigationTransition)
-
- case .subscriptionUpsell:
- OnboardingSubscriptionContent(
- onSubscribe: {
- // Handle subscription flow
onboardingState.completeOnboarding()
onComplete()
}
)
.transition(navigationTransition)
+
+ case .subscriptionUpsell:
+ // Subscription removed from onboarding — app is free
+ // Immediately complete if we somehow land here
+ EmptyView()
+ .onAppear {
+ onboardingState.completeOnboarding()
+ onComplete()
+ }
+ .transition(navigationTransition)
}
}
.animation(.easeInOut(duration: 0.3), value: onboardingState.currentStep)
diff --git a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift
index e56245a..ae226c8 100644
--- a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift
+++ b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift
@@ -1,6 +1,12 @@
import SwiftUI
import ComposeApp
+/// Tab selection for task browsing
+enum OnboardingTaskTab: String, CaseIterable {
+ case forYou = "For You"
+ case browse = "Browse All"
+}
+
/// Screen 6: First task prompt with suggested templates - Content only (no navigation bar)
struct OnboardingFirstTaskContent: View {
var residenceName: String
@@ -13,10 +19,12 @@ struct OnboardingFirstTaskContent: View {
@State private var isCreatingTasks = false
@State private var expandedCategories: Set = []
@State private var isAnimating = false
+ @State private var selectedTab: OnboardingTaskTab = .forYou
+ @State private var forYouTemplates: [OnboardingTaskTemplate] = []
+ @State private var isLoadingSuggestions = false
@Environment(\.colorScheme) var colorScheme
- /// Maximum tasks allowed for free tier (matches API TierLimits)
- private let maxTasksAllowed = 5
+ // No task selection limit — users can add as many as they want
/// Category colors by name (used for both API and fallback templates)
private static let categoryColors: [String: Color] = [
@@ -173,7 +181,7 @@ struct OnboardingFirstTaskContent: View {
}
private var isAtMaxSelection: Bool {
- selectedTasks.count >= maxTasksAllowed
+ false
}
var body: some View {
@@ -304,88 +312,107 @@ struct OnboardingFirstTaskContent: View {
Image(systemName: isAtMaxSelection ? "checkmark.seal.fill" : "checkmark.circle.fill")
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
- Text("\(selectedCount)/\(maxTasksAllowed) tasks selected")
+ Text("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected")
.font(.system(size: 14, weight: .semibold))
- .foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
+ .foregroundColor(Color.appPrimary)
}
.padding(.horizontal, 18)
.padding(.vertical, 10)
- .background((isAtMaxSelection ? Color.appAccent : Color.appPrimary).opacity(0.1))
+ .background(Color.appPrimary.opacity(0.1))
.clipShape(Capsule())
.animation(.spring(response: 0.3), value: selectedCount)
- .accessibilityLabel("\(selectedCount) of \(maxTasksAllowed) tasks selected")
+ .accessibilityLabel("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected")
- // Task categories
- VStack(spacing: 12) {
- ForEach(taskCategories) { category in
- OrganicTaskCategorySection(
- category: category,
- selectedTasks: $selectedTasks,
- isExpanded: expandedCategories.contains(category.name),
- isAtMaxSelection: isAtMaxSelection,
- onToggleExpand: {
- let isExpanding = !expandedCategories.contains(category.name)
- withAnimation(.spring(response: 0.3)) {
- if expandedCategories.contains(category.name) {
- expandedCategories.remove(category.name)
- } else {
- expandedCategories.insert(category.name)
+ // Tab bar
+ OnboardingTaskTabBar(selectedTab: $selectedTab)
+ .padding(.horizontal, OrganicSpacing.comfortable)
+
+ // Tab content
+ switch selectedTab {
+ case .forYou:
+ // For You tab — personalized suggestions
+ ForYouTasksTab(
+ forYouTemplates: forYouTemplates,
+ isLoading: isLoadingSuggestions,
+ selectedTasks: $selectedTasks,
+ isAtMaxSelection: isAtMaxSelection,
+ hasResidence: onboardingState.createdResidenceId != nil
+ )
+ .padding(.horizontal, OrganicSpacing.comfortable)
+
+ case .browse:
+ // Browse tab — existing category browser
+ VStack(spacing: 12) {
+ ForEach(taskCategories) { category in
+ OrganicTaskCategorySection(
+ category: category,
+ selectedTasks: $selectedTasks,
+ isExpanded: expandedCategories.contains(category.name),
+ isAtMaxSelection: isAtMaxSelection,
+ onToggleExpand: {
+ let isExpanding = !expandedCategories.contains(category.name)
+ withAnimation(.spring(response: 0.3)) {
+ if expandedCategories.contains(category.name) {
+ expandedCategories.remove(category.name)
+ } else {
+ expandedCategories.insert(category.name)
+ }
}
- }
- if isExpanding {
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
- withAnimation {
- proxy.scrollTo(category.name, anchor: .top)
+ if isExpanding {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
+ withAnimation {
+ proxy.scrollTo(category.name, anchor: .top)
+ }
}
}
}
- }
- )
- .id(category.name)
- }
- }
- .padding(.horizontal, OrganicSpacing.comfortable)
-
- // Quick add all popular
- Button(action: selectPopularTasks) {
- HStack(spacing: 8) {
- Image(systemName: "sparkles")
- .font(.system(size: 16, weight: .semibold))
-
- Text("Add Most Popular")
- .font(.system(size: 16, weight: .semibold))
- }
- .foregroundStyle(
- LinearGradient(
- colors: [Color.appPrimary, Color.appAccent],
- startPoint: .leading,
- endPoint: .trailing
- )
- )
- .frame(maxWidth: .infinity)
- .frame(height: 56)
- .background(
- LinearGradient(
- colors: [Color.appPrimary.opacity(0.1), Color.appAccent.opacity(0.1)],
- startPoint: .leading,
- endPoint: .trailing
- )
- )
- .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 16, style: .continuous)
- .stroke(
- LinearGradient(
- colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)],
- startPoint: .leading,
- endPoint: .trailing
- ),
- lineWidth: 1.5
)
- )
+ .id(category.name)
+ }
+ }
+ .padding(.horizontal, OrganicSpacing.comfortable)
+
+ // Quick add all popular
+ Button(action: selectPopularTasks) {
+ HStack(spacing: 8) {
+ Image(systemName: "sparkles")
+ .font(.system(size: 16, weight: .semibold))
+
+ Text("Add Most Popular")
+ .font(.system(size: 16, weight: .semibold))
+ }
+ .foregroundStyle(
+ LinearGradient(
+ colors: [Color.appPrimary, Color.appAccent],
+ startPoint: .leading,
+ endPoint: .trailing
+ )
+ )
+ .frame(maxWidth: .infinity)
+ .frame(height: 56)
+ .background(
+ LinearGradient(
+ colors: [Color.appPrimary.opacity(0.1), Color.appAccent.opacity(0.1)],
+ startPoint: .leading,
+ endPoint: .trailing
+ )
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .stroke(
+ LinearGradient(
+ colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)],
+ startPoint: .leading,
+ endPoint: .trailing
+ ),
+ lineWidth: 1.5
+ )
+ )
+ }
+ .padding(.horizontal, OrganicSpacing.comfortable)
+ .a11yButton("Add popular tasks")
}
- .padding(.horizontal, OrganicSpacing.comfortable)
- .a11yButton("Add popular tasks")
}
.padding(.bottom, 140) // Space for button
}
@@ -448,6 +475,8 @@ struct OnboardingFirstTaskContent: View {
if let first = taskCategories.first?.name {
expandedCategories.insert(first)
}
+ // Build "For You" suggestions based on home profile
+ buildForYouSuggestions()
}
.onDisappear {
isAnimating = false
@@ -457,11 +486,9 @@ struct OnboardingFirstTaskContent: View {
private func selectPopularTasks() {
withAnimation(.spring(response: 0.3)) {
if !onboardingState.regionalTemplates.isEmpty {
- // API templates: select the first N tasks (they're ordered by display_order)
+ // API templates: select the first tasks (they're ordered by display_order)
for task in allTasks {
- if selectedTasks.count < maxTasksAllowed {
- selectedTasks.insert(task.id)
- }
+ selectedTasks.insert(task.id)
}
} else {
// Fallback: select hardcoded popular tasks
@@ -473,14 +500,164 @@ struct OnboardingFirstTaskContent: View {
"Clean Refrigerator Coils"
]
for task in allTasks where popularTaskTitles.contains(task.title) {
- if selectedTasks.count < maxTasksAllowed {
- selectedTasks.insert(task.id)
- }
+ selectedTasks.insert(task.id)
}
}
}
}
+ /// Build personalized "For You" suggestions based on the home profile selections
+ private func buildForYouSuggestions() {
+ var suggestions: [ForYouSuggestion] = []
+
+ let state = onboardingState
+
+ // HVAC-related suggestions based on heating/cooling type
+ if state.pendingHeatingType != nil || state.pendingCoolingType != nil {
+ suggestions.append(ForYouSuggestion(
+ template: OnboardingTaskTemplate(icon: "fanblades.fill", title: "Change HVAC Filter", category: "hvac", frequency: "monthly", color: .appPrimary),
+ relevance: .great, reason: "Based on your HVAC system"
+ ))
+ }
+ if state.pendingHeatingType == "gas_furnace" || state.pendingHeatingType == "boiler" {
+ suggestions.append(ForYouSuggestion(
+ template: OnboardingTaskTemplate(icon: "flame.fill", title: "Inspect Furnace", category: "hvac", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange),
+ relevance: .great, reason: "You have a gas system"
+ ))
+ }
+ if state.pendingCoolingType == "central_ac" || state.pendingCoolingType == "heat_pump" {
+ suggestions.append(ForYouSuggestion(
+ template: OnboardingTaskTemplate(icon: "air.conditioner.horizontal.fill", title: "Schedule AC Tune-Up", category: "hvac", frequency: "yearly", color: .appPrimary),
+ relevance: .great, reason: "Central cooling needs annual service"
+ ))
+ }
+
+ // Water heater
+ if state.pendingWaterHeaterType != nil {
+ suggestions.append(ForYouSuggestion(
+ template: OnboardingTaskTemplate(icon: "bolt.horizontal.fill", title: "Flush Water Heater", category: "plumbing", frequency: "yearly", color: Color(hex: "#FF9500") ?? .orange),
+ relevance: state.pendingWaterHeaterType?.contains("tank") == true ? .great : .good,
+ reason: "Extends water heater life"
+ ))
+ }
+
+ // Pool
+ if state.pendingHasPool {
+ suggestions.append(ForYouSuggestion(
+ template: OnboardingTaskTemplate(icon: "figure.pool.swim", title: "Check Pool Chemistry", category: "exterior", frequency: "weekly", color: .appSecondary),
+ relevance: .great, reason: "You have a pool"
+ ))
+ }
+
+ // Sprinklers
+ if state.pendingHasSprinklerSystem {
+ suggestions.append(ForYouSuggestion(
+ template: OnboardingTaskTemplate(icon: "sprinkler.and.droplets.fill", title: "Check Sprinkler System", category: "landscaping", frequency: "monthly", color: Color(hex: "#34C759") ?? .green),
+ relevance: .great, reason: "You have sprinklers"
+ ))
+ }
+
+ // Fireplace
+ if state.pendingHasFireplace {
+ suggestions.append(ForYouSuggestion(
+ template: OnboardingTaskTemplate(icon: "fireplace.fill", title: "Inspect Chimney & Fireplace", category: "interior", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange),
+ relevance: .great, reason: "You have a fireplace"
+ ))
+ }
+
+ // Garage
+ if state.pendingHasGarage {
+ suggestions.append(ForYouSuggestion(
+ template: OnboardingTaskTemplate(icon: "lock.fill", title: "Test Garage Door Safety", category: "safety", frequency: "monthly", color: .appSecondary),
+ relevance: .good, reason: "You have a garage"
+ ))
+ }
+
+ // Basement
+ if state.pendingHasBasement {
+ suggestions.append(ForYouSuggestion(
+ template: OnboardingTaskTemplate(icon: "drop.fill", title: "Check Basement for Moisture", category: "interior", frequency: "monthly", color: .appSecondary),
+ relevance: .good, reason: "You have a basement"
+ ))
+ }
+
+ // Septic
+ if state.pendingHasSeptic {
+ suggestions.append(ForYouSuggestion(
+ template: OnboardingTaskTemplate(icon: "drop.triangle.fill", title: "Schedule Septic Inspection", category: "plumbing", frequency: "yearly", color: .appPrimary),
+ relevance: .great, reason: "You have a septic system"
+ ))
+ }
+
+ // Attic
+ if state.pendingHasAttic {
+ suggestions.append(ForYouSuggestion(
+ template: OnboardingTaskTemplate(icon: "arrow.up.square.fill", title: "Inspect Attic Insulation", category: "interior", frequency: "yearly", color: Color(hex: "#AF52DE") ?? .purple),
+ relevance: .good, reason: "You have an attic"
+ ))
+ }
+
+ // Roof-based
+ if state.pendingRoofType != nil {
+ suggestions.append(ForYouSuggestion(
+ template: OnboardingTaskTemplate(icon: "cloud.rain.fill", title: "Clean Gutters", category: "exterior", frequency: "semiannually", color: .appSecondary),
+ relevance: .great, reason: "Protects your roof"
+ ))
+ }
+
+ // Always-recommended essentials (lower priority)
+ suggestions.append(ForYouSuggestion(
+ template: OnboardingTaskTemplate(icon: "smoke.fill", title: "Test Smoke Detectors", category: "safety", frequency: "monthly", color: .appError),
+ relevance: .good, reason: "Essential safety task"
+ ))
+ suggestions.append(ForYouSuggestion(
+ template: OnboardingTaskTemplate(icon: "drop.fill", title: "Check for Leaks", category: "plumbing", frequency: "monthly", color: .appSecondary),
+ relevance: .good, reason: "Prevents water damage"
+ ))
+
+ // Landscaping
+ if state.pendingLandscapingType == "lawn" || state.pendingLandscapingType == "garden" || state.pendingLandscapingType == "mixed" {
+ suggestions.append(ForYouSuggestion(
+ template: OnboardingTaskTemplate(icon: "leaf.fill", title: "Lawn Care", category: "landscaping", frequency: "weekly", color: Color(hex: "#34C759") ?? .green),
+ relevance: .good, reason: "Based on your landscaping"
+ ))
+ }
+
+ // Sort: great first, then good; deduplicate by title
+ var seen = Set()
+ let sorted = suggestions
+ .sorted { $0.relevance.rawValue > $1.relevance.rawValue }
+ .filter { seen.insert($0.template.title).inserted }
+
+ forYouTemplates = sorted.map { $0.template }
+
+ // If we have personalized suggestions, default to For You tab
+ if !forYouTemplates.isEmpty && hasAnyHomeProfileData() {
+ selectedTab = .forYou
+ } else {
+ selectedTab = .browse
+ }
+ }
+
+ /// Check if user filled in any home profile data
+ private func hasAnyHomeProfileData() -> Bool {
+ let s = onboardingState
+ return s.pendingHeatingType != nil ||
+ s.pendingCoolingType != nil ||
+ s.pendingWaterHeaterType != nil ||
+ s.pendingRoofType != nil ||
+ s.pendingHasPool ||
+ s.pendingHasSprinklerSystem ||
+ s.pendingHasSeptic ||
+ s.pendingHasFireplace ||
+ s.pendingHasGarage ||
+ s.pendingHasBasement ||
+ s.pendingHasAttic ||
+ s.pendingExteriorType != nil ||
+ s.pendingFlooringPrimary != nil ||
+ s.pendingLandscapingType != nil
+ }
+
private func addSelectedTasks() {
// If no tasks selected, just skip
if selectedTasks.isEmpty {
@@ -497,9 +674,14 @@ struct OnboardingFirstTaskContent: View {
isCreatingTasks = true
- let selectedTemplates = allTasks.filter { selectedTasks.contains($0.id) }
+ // Collect from both browse and For You templates
+ let allAvailable = allTasks + forYouTemplates
+ let selectedTemplates = allAvailable.filter { selectedTasks.contains($0.id) }
+ // Deduplicate by title (same task might exist in both tabs)
+ var seenTitles = Set()
+ let uniqueTemplates = selectedTemplates.filter { seenTitles.insert($0.title).inserted }
var completedCount = 0
- let totalCount = selectedTemplates.count
+ let totalCount = uniqueTemplates.count
// Safety: if no templates matched (shouldn't happen), skip
if totalCount == 0 {
@@ -516,7 +698,7 @@ struct OnboardingFirstTaskContent: View {
print("🏠 ONBOARDING: Creating \(totalCount) tasks for residence \(residenceId)")
- for template in selectedTemplates {
+ for template in uniqueTemplates {
// Look up category ID from DataManager
let categoryId: Int32? = {
return dataManager.taskCategories.first { $0.name.caseInsensitiveCompare(template.category) == .orderedSame }?.id
@@ -760,6 +942,230 @@ struct OnboardingTaskTemplate: Identifiable {
let color: Color
}
+// MARK: - For You Suggestion Model
+
+enum SuggestionRelevance: Int {
+ case good = 1
+ case great = 2
+}
+
+struct ForYouSuggestion {
+ let template: OnboardingTaskTemplate
+ let relevance: SuggestionRelevance
+ let reason: String
+}
+
+// MARK: - Tab Bar
+
+private struct OnboardingTaskTabBar: View {
+ @Binding var selectedTab: OnboardingTaskTab
+
+ var body: some View {
+ HStack(spacing: 0) {
+ ForEach(OnboardingTaskTab.allCases, id: \.self) { tab in
+ Button {
+ withAnimation(.spring(response: 0.3)) {
+ selectedTab = tab
+ }
+ } label: {
+ VStack(spacing: 6) {
+ HStack(spacing: 6) {
+ if tab == .forYou {
+ Image(systemName: "sparkles")
+ .font(.system(size: 12, weight: .semibold))
+ } else {
+ Image(systemName: "square.grid.2x2.fill")
+ .font(.system(size: 12, weight: .semibold))
+ }
+ Text(tab.rawValue)
+ .font(.system(size: 14, weight: .semibold))
+ }
+ .foregroundColor(selectedTab == tab ? Color.appPrimary : Color.appTextSecondary)
+ .frame(maxWidth: .infinity)
+
+ // Indicator
+ RoundedRectangle(cornerRadius: 1.5)
+ .fill(selectedTab == tab ? Color.appPrimary : Color.clear)
+ .frame(height: 3)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.vertical, 4)
+ .background(
+ ZStack {
+ Color.appBackgroundSecondary
+ GrainTexture(opacity: 0.01)
+ }
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
+ }
+}
+
+// MARK: - For You Tasks Tab
+
+private struct ForYouTasksTab: View {
+ let forYouTemplates: [OnboardingTaskTemplate]
+ let isLoading: Bool
+ @Binding var selectedTasks: Set
+ let isAtMaxSelection: Bool
+ let hasResidence: Bool
+
+ var body: some View {
+ if isLoading {
+ VStack(spacing: 16) {
+ ProgressView()
+ .progressViewStyle(CircularProgressViewStyle(tint: Color.appPrimary))
+ .scaleEffect(1.2)
+ Text("Generating suggestions...")
+ .font(.system(size: 15, weight: .medium))
+ .foregroundColor(Color.appTextSecondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 40)
+ } else if forYouTemplates.isEmpty {
+ VStack(spacing: 16) {
+ ZStack {
+ Circle()
+ .fill(Color.appPrimary.opacity(0.1))
+ .frame(width: 64, height: 64)
+ Image(systemName: "sparkles")
+ .font(.system(size: 28))
+ .foregroundColor(Color.appPrimary)
+ }
+
+ Text("No personalized suggestions yet")
+ .font(.system(size: 17, weight: .semibold))
+ .foregroundColor(Color.appTextPrimary)
+
+ Text("Try the Browse tab to explore tasks by category,\nor add home details for better suggestions.")
+ .font(.system(size: 14, weight: .medium))
+ .foregroundColor(Color.appTextSecondary)
+ .multilineTextAlignment(.center)
+ .lineSpacing(4)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 30)
+ .padding(.horizontal, 16)
+ .background(
+ ZStack {
+ Color.appBackgroundSecondary
+ GrainTexture(opacity: 0.01)
+ }
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
+ .naturalShadow(.subtle)
+ } else {
+ VStack(spacing: 0) {
+ ForEach(Array(forYouTemplates.enumerated()), id: \.element.id) { index, template in
+ let isSelected = selectedTasks.contains(template.id)
+ let isDisabled = isAtMaxSelection && !isSelected
+
+ ForYouSuggestionRow(
+ template: template,
+ isSelected: isSelected,
+ isDisabled: isDisabled,
+ relevance: index < 3 ? .great : .good,
+ onTap: {
+ withAnimation(.spring(response: 0.2)) {
+ if isSelected {
+ selectedTasks.remove(template.id)
+ } else if !isAtMaxSelection {
+ selectedTasks.insert(template.id)
+ }
+ }
+ }
+ )
+
+ if index < forYouTemplates.count - 1 {
+ Divider()
+ .padding(.leading, 60)
+ }
+ }
+ }
+ .background(
+ ZStack {
+ Color.appBackgroundSecondary
+ GrainTexture(opacity: 0.01)
+ }
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+ .naturalShadow(.subtle)
+ }
+ }
+}
+
+// MARK: - For You Suggestion Row
+
+private struct ForYouSuggestionRow: View {
+ let template: OnboardingTaskTemplate
+ let isSelected: Bool
+ let isDisabled: Bool
+ let relevance: SuggestionRelevance
+ var onTap: () -> Void
+
+ var body: some View {
+ Button(action: onTap) {
+ HStack(spacing: 14) {
+ // Checkbox
+ ZStack {
+ Circle()
+ .stroke(isSelected ? template.color : Color.appTextSecondary.opacity(isDisabled ? 0.15 : 0.3), lineWidth: 2)
+ .frame(width: 28, height: 28)
+
+ if isSelected {
+ Circle()
+ .fill(template.color)
+ .frame(width: 28, height: 28)
+
+ Image(systemName: "checkmark")
+ .font(.system(size: 12, weight: .bold))
+ .foregroundColor(.white)
+ }
+ }
+
+ // Task icon
+ Image(systemName: template.icon)
+ .font(.system(size: 18, weight: .medium))
+ .foregroundColor(template.color.opacity(isDisabled ? 0.3 : 0.8))
+ .frame(width: 24)
+
+ // Task info
+ VStack(alignment: .leading, spacing: 2) {
+ Text(template.title)
+ .font(.system(size: 15, weight: .medium))
+ .foregroundColor(isDisabled ? Color.appTextSecondary.opacity(0.5) : Color.appTextPrimary)
+
+ Text(template.frequency.capitalized)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundColor(Color.appTextSecondary.opacity(isDisabled ? 0.5 : 1))
+ }
+
+ Spacer()
+
+ // Relevance badge
+ Text(relevance == .great ? "Great match" : "Good match")
+ .font(.system(size: 10, weight: .bold))
+ .foregroundColor(relevance == .great ? Color.appPrimary : Color.appTextSecondary)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(
+ (relevance == .great ? Color.appPrimary : Color.appTextSecondary).opacity(0.1)
+ )
+ .clipShape(Capsule())
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .disabled(isDisabled)
+ .accessibilityLabel("\(template.title), \(template.frequency.capitalized)")
+ .accessibilityValue(isSelected ? "selected" : "not selected")
+ }
+}
+
// MARK: - Legacy wrapper with navigation bar (for backwards compatibility)
struct OnboardingFirstTaskView: View {
diff --git a/iosApp/iosApp/Onboarding/OnboardingHomeProfileView.swift b/iosApp/iosApp/Onboarding/OnboardingHomeProfileView.swift
new file mode 100644
index 0000000..fbb8a07
--- /dev/null
+++ b/iosApp/iosApp/Onboarding/OnboardingHomeProfileView.swift
@@ -0,0 +1,517 @@
+import SwiftUI
+
+/// Screen: Home profile — systems, features, exterior, interior
+struct OnboardingHomeProfileContent: View {
+ var onContinue: () -> Void
+ var onSkip: () -> Void
+
+ @ObservedObject private var onboardingState = OnboardingState.shared
+ @State private var isAnimating = false
+ @Environment(\.colorScheme) var colorScheme
+
+ var body: some View {
+ ZStack {
+ WarmGradientBackground()
+ .a11yDecorative()
+
+ // Decorative blobs
+ GeometryReader { geo in
+ OrganicBlobShape(variation: 2)
+ .fill(
+ RadialGradient(
+ colors: [
+ Color.appAccent.opacity(0.08),
+ Color.appAccent.opacity(0.02),
+ Color.clear
+ ],
+ center: .center,
+ startRadius: 0,
+ endRadius: geo.size.width * 0.35
+ )
+ )
+ .frame(width: geo.size.width * 0.6, height: geo.size.height * 0.35)
+ .offset(x: -geo.size.width * 0.15, y: geo.size.height * 0.05)
+ .blur(radius: 25)
+
+ OrganicBlobShape(variation: 0)
+ .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.3)
+ .offset(x: geo.size.width * 0.55, y: geo.size.height * 0.6)
+ .blur(radius: 20)
+ }
+ .a11yDecorative()
+
+ VStack(spacing: 0) {
+ ScrollView(showsIndicators: false) {
+ VStack(spacing: OrganicSpacing.comfortable) {
+ // Header
+ VStack(spacing: 16) {
+ 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
+ )
+
+ ZStack {
+ Circle()
+ .fill(
+ LinearGradient(
+ colors: [Color.appPrimary, Color.appSecondary],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ )
+ .frame(width: 90, height: 90)
+
+ Image(systemName: "house.lodge.fill")
+ .font(.system(size: 40))
+ .foregroundColor(.white)
+ }
+ .naturalShadow(.pronounced)
+ }
+
+ Text("Tell us about your home")
+ .font(.system(size: 26, weight: .bold, design: .rounded))
+ .foregroundColor(Color.appTextPrimary)
+ .multilineTextAlignment(.center)
+ .a11yHeader()
+
+ Text("All optional -- helps us personalize your plan")
+ .font(.system(size: 15, weight: .medium))
+ .foregroundColor(Color.appTextSecondary)
+ .multilineTextAlignment(.center)
+ .lineSpacing(4)
+ }
+ .padding(.top, OrganicSpacing.cozy)
+
+ // Systems section
+ ProfileSection(title: "Systems", icon: "gearshape.2.fill", color: .appPrimary) {
+ VStack(spacing: 12) {
+ ProfilePicker(
+ label: "Heating",
+ icon: "flame.fill",
+ selection: $onboardingState.pendingHeatingType,
+ options: HomeProfileOptions.heatingTypes
+ )
+ ProfilePicker(
+ label: "Cooling",
+ icon: "snowflake",
+ selection: $onboardingState.pendingCoolingType,
+ options: HomeProfileOptions.coolingTypes
+ )
+ ProfilePicker(
+ label: "Water Heater",
+ icon: "drop.fill",
+ selection: $onboardingState.pendingWaterHeaterType,
+ options: HomeProfileOptions.waterHeaterTypes
+ )
+ }
+ }
+
+ // Features section
+ ProfileSection(title: "Features", icon: "star.fill", color: .appAccent) {
+ HomeFeatureChipGrid(
+ features: [
+ FeatureToggle(label: "Pool", icon: "figure.pool.swim", isOn: $onboardingState.pendingHasPool),
+ FeatureToggle(label: "Sprinklers", icon: "sprinkler.and.droplets.fill", isOn: $onboardingState.pendingHasSprinklerSystem),
+ FeatureToggle(label: "Fireplace", icon: "fireplace.fill", isOn: $onboardingState.pendingHasFireplace),
+ FeatureToggle(label: "Garage", icon: "car.fill", isOn: $onboardingState.pendingHasGarage),
+ FeatureToggle(label: "Basement", icon: "arrow.down.square.fill", isOn: $onboardingState.pendingHasBasement),
+ FeatureToggle(label: "Attic", icon: "arrow.up.square.fill", isOn: $onboardingState.pendingHasAttic),
+ FeatureToggle(label: "Septic", icon: "drop.triangle.fill", isOn: $onboardingState.pendingHasSeptic),
+ ]
+ )
+ }
+
+ // Exterior section
+ ProfileSection(title: "Exterior", icon: "house.fill", color: Color(hex: "#34C759") ?? .green) {
+ VStack(spacing: 12) {
+ ProfilePicker(
+ label: "Roof Type",
+ icon: "triangle.fill",
+ selection: $onboardingState.pendingRoofType,
+ options: HomeProfileOptions.roofTypes
+ )
+ ProfilePicker(
+ label: "Exterior",
+ icon: "square.stack.3d.up.fill",
+ selection: $onboardingState.pendingExteriorType,
+ options: HomeProfileOptions.exteriorTypes
+ )
+ ProfilePicker(
+ label: "Landscaping",
+ icon: "leaf.fill",
+ selection: $onboardingState.pendingLandscapingType,
+ options: HomeProfileOptions.landscapingTypes
+ )
+ }
+ }
+
+ // Interior section
+ ProfileSection(title: "Interior", icon: "sofa.fill", color: Color(hex: "#AF52DE") ?? .purple) {
+ ProfilePicker(
+ label: "Primary Flooring",
+ icon: "square.grid.3x3.fill",
+ selection: $onboardingState.pendingFlooringPrimary,
+ options: HomeProfileOptions.flooringTypes
+ )
+ }
+ }
+ .padding(.bottom, 140) // Space for button
+ }
+
+ // Bottom action area
+ VStack(spacing: 14) {
+ Button(action: onContinue) {
+ 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(
+ LinearGradient(
+ colors: [Color.appPrimary, Color.appSecondary],
+ startPoint: .leading,
+ endPoint: .trailing
+ )
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+ .naturalShadow(.medium)
+ }
+ }
+ .padding(.horizontal, OrganicSpacing.comfortable)
+ .padding(.bottom, OrganicSpacing.airy)
+ .background(
+ LinearGradient(
+ colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary],
+ startPoint: .top,
+ endPoint: .center
+ )
+ .frame(height: 60)
+ .offset(y: -60)
+ , alignment: .top
+ )
+ }
+ }
+ .onAppear { isAnimating = true }
+ .onDisappear { isAnimating = false }
+ }
+}
+
+// MARK: - Profile Section Card
+
+private struct ProfileSection: View {
+ let title: String
+ let icon: String
+ let color: Color
+ @ViewBuilder var content: Content
+
+ @Environment(\.colorScheme) var colorScheme
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ // Section header
+ HStack(spacing: 10) {
+ ZStack {
+ Circle()
+ .fill(
+ LinearGradient(
+ colors: [color, color.opacity(0.7)],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ )
+ .frame(width: 32, height: 32)
+
+ Image(systemName: icon)
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundColor(.white)
+ }
+
+ Text(title)
+ .font(.system(size: 16, weight: .bold, design: .rounded))
+ .foregroundColor(Color.appTextPrimary)
+ }
+
+ content
+ }
+ .padding(16)
+ .background(
+ ZStack {
+ Color.appBackgroundSecondary
+ GrainTexture(opacity: 0.01)
+ }
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
+ .naturalShadow(.subtle)
+ .padding(.horizontal, OrganicSpacing.comfortable)
+ }
+}
+
+// MARK: - Profile Picker (compact dropdown)
+
+private struct ProfilePicker: View {
+ let label: String
+ let icon: String
+ @Binding var selection: String?
+ let options: [HomeProfileOption]
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Image(systemName: icon)
+ .font(.system(size: 16, weight: .medium))
+ .foregroundColor(Color.appPrimary)
+ .frame(width: 24)
+
+ Text(label)
+ .font(.system(size: 15, weight: .medium))
+ .foregroundColor(Color.appTextPrimary)
+
+ Spacer()
+
+ Menu {
+ Button("None") {
+ withAnimation(.spring(response: 0.3)) {
+ selection = nil
+ }
+ }
+ ForEach(options, id: \.value) { option in
+ Button(option.display) {
+ withAnimation(.spring(response: 0.3)) {
+ selection = option.value
+ }
+ }
+ }
+ } label: {
+ HStack(spacing: 6) {
+ Text(displayValue)
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundColor(selection != nil ? Color.appPrimary : Color.appTextSecondary)
+
+ Image(systemName: "chevron.up.chevron.down")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundColor(Color.appTextSecondary)
+ }
+ .fixedSize()
+ .padding(.horizontal, 14)
+ .padding(.vertical, 8)
+ .background(
+ (selection != nil ? Color.appPrimary : Color.appTextSecondary).opacity(0.1)
+ )
+ .clipShape(Capsule())
+ }
+ }
+ }
+
+ private var displayValue: String {
+ guard let selection = selection else { return "Select" }
+ return options.first { $0.value == selection }?.display ?? selection
+ }
+}
+
+// MARK: - Feature Chip Toggle Grid
+
+private struct FeatureToggle: Identifiable {
+ let id = UUID()
+ let label: String
+ let icon: String
+ @Binding var isOn: Bool
+}
+
+private struct HomeFeatureChipGrid: View {
+ let features: [FeatureToggle]
+
+ var body: some View {
+ FlowLayout(spacing: 10) {
+ ForEach(features) { feature in
+ HomeFeatureChip(
+ label: feature.label,
+ icon: feature.icon,
+ isSelected: feature.isOn,
+ onTap: {
+ withAnimation(.spring(response: 0.2)) {
+ feature.isOn.toggle()
+ }
+ }
+ )
+ }
+ }
+ }
+}
+
+private struct HomeFeatureChip: View {
+ let label: String
+ let icon: String
+ let isSelected: Bool
+ var onTap: () -> Void
+
+ var body: some View {
+ Button(action: onTap) {
+ HStack(spacing: 8) {
+ Image(systemName: icon)
+ .font(.system(size: 14, weight: .medium))
+
+ Text(label)
+ .font(.system(size: 14, weight: .semibold))
+ }
+ .foregroundColor(isSelected ? .white : Color.appTextPrimary)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ .background(
+ isSelected
+ ? AnyShapeStyle(LinearGradient(
+ colors: [Color.appPrimary, Color.appSecondary],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ ))
+ : AnyShapeStyle(Color.appTextSecondary.opacity(0.1))
+ )
+ .clipShape(Capsule())
+ .overlay(
+ Capsule()
+ .stroke(
+ isSelected ? Color.clear : Color.appTextSecondary.opacity(0.2),
+ lineWidth: 1
+ )
+ )
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(label)
+ .accessibilityValue(isSelected ? "selected" : "not selected")
+ }
+}
+
+// MARK: - Home Profile Options
+
+struct HomeProfileOption {
+ let value: String
+ let display: String
+}
+
+enum HomeProfileOptions {
+ static let heatingTypes: [HomeProfileOption] = [
+ HomeProfileOption(value: "gas_furnace", display: "Gas Furnace"),
+ HomeProfileOption(value: "electric_furnace", display: "Electric Furnace"),
+ HomeProfileOption(value: "heat_pump", display: "Heat Pump"),
+ HomeProfileOption(value: "boiler", display: "Boiler"),
+ HomeProfileOption(value: "radiant", display: "Radiant"),
+ HomeProfileOption(value: "other", display: "Other"),
+ ]
+
+ static let coolingTypes: [HomeProfileOption] = [
+ HomeProfileOption(value: "central_ac", display: "Central AC"),
+ HomeProfileOption(value: "window_ac", display: "Window AC"),
+ HomeProfileOption(value: "heat_pump", display: "Heat Pump"),
+ HomeProfileOption(value: "evaporative", display: "Evaporative"),
+ HomeProfileOption(value: "none", display: "None"),
+ HomeProfileOption(value: "other", display: "Other"),
+ ]
+
+ static let waterHeaterTypes: [HomeProfileOption] = [
+ HomeProfileOption(value: "tank_gas", display: "Tank (Gas)"),
+ HomeProfileOption(value: "tank_electric", display: "Tank (Electric)"),
+ HomeProfileOption(value: "tankless_gas", display: "Tankless (Gas)"),
+ HomeProfileOption(value: "tankless_electric", display: "Tankless (Electric)"),
+ HomeProfileOption(value: "heat_pump", display: "Heat Pump"),
+ HomeProfileOption(value: "solar", display: "Solar"),
+ HomeProfileOption(value: "other", display: "Other"),
+ ]
+
+ static let roofTypes: [HomeProfileOption] = [
+ HomeProfileOption(value: "asphalt_shingle", display: "Asphalt Shingle"),
+ HomeProfileOption(value: "metal", display: "Metal"),
+ HomeProfileOption(value: "tile", display: "Tile"),
+ HomeProfileOption(value: "slate", display: "Slate"),
+ HomeProfileOption(value: "wood_shake", display: "Wood Shake"),
+ HomeProfileOption(value: "flat", display: "Flat"),
+ HomeProfileOption(value: "other", display: "Other"),
+ ]
+
+ static let exteriorTypes: [HomeProfileOption] = [
+ HomeProfileOption(value: "brick", display: "Brick"),
+ HomeProfileOption(value: "vinyl_siding", display: "Vinyl Siding"),
+ HomeProfileOption(value: "wood_siding", display: "Wood Siding"),
+ HomeProfileOption(value: "stucco", display: "Stucco"),
+ HomeProfileOption(value: "stone", display: "Stone"),
+ HomeProfileOption(value: "fiber_cement", display: "Fiber Cement"),
+ HomeProfileOption(value: "other", display: "Other"),
+ ]
+
+ static let flooringTypes: [HomeProfileOption] = [
+ HomeProfileOption(value: "hardwood", display: "Hardwood"),
+ HomeProfileOption(value: "laminate", display: "Laminate"),
+ HomeProfileOption(value: "tile", display: "Tile"),
+ HomeProfileOption(value: "carpet", display: "Carpet"),
+ HomeProfileOption(value: "vinyl", display: "Vinyl"),
+ HomeProfileOption(value: "concrete", display: "Concrete"),
+ HomeProfileOption(value: "other", display: "Other"),
+ ]
+
+ static let landscapingTypes: [HomeProfileOption] = [
+ HomeProfileOption(value: "lawn", display: "Lawn"),
+ HomeProfileOption(value: "desert", display: "Desert"),
+ HomeProfileOption(value: "xeriscape", display: "Xeriscape"),
+ HomeProfileOption(value: "garden", display: "Garden"),
+ HomeProfileOption(value: "mixed", display: "Mixed"),
+ HomeProfileOption(value: "none", display: "None"),
+ HomeProfileOption(value: "other", display: "Other"),
+ ]
+}
+
+// MARK: - Preview
+
+#Preview {
+ OnboardingHomeProfileContent(
+ onContinue: {},
+ onSkip: {}
+ )
+}
diff --git a/iosApp/iosApp/Onboarding/OnboardingState.swift b/iosApp/iosApp/Onboarding/OnboardingState.swift
index 6939f6f..b0ceb98 100644
--- a/iosApp/iosApp/Onboarding/OnboardingState.swift
+++ b/iosApp/iosApp/Onboarding/OnboardingState.swift
@@ -45,6 +45,23 @@ class OnboardingState: ObservableObject {
/// Whether regional templates are currently loading
@Published var isLoadingTemplates: Bool = false
+ // MARK: - Home Profile State (collected during onboarding)
+
+ @Published var pendingHeatingType: String? = nil
+ @Published var pendingCoolingType: String? = nil
+ @Published var pendingWaterHeaterType: String? = nil
+ @Published var pendingRoofType: String? = nil
+ @Published var pendingHasPool: Bool = false
+ @Published var pendingHasSprinklerSystem: Bool = false
+ @Published var pendingHasSeptic: Bool = false
+ @Published var pendingHasFireplace: Bool = false
+ @Published var pendingHasGarage: Bool = false
+ @Published var pendingHasBasement: Bool = false
+ @Published var pendingHasAttic: Bool = false
+ @Published var pendingExteriorType: String? = nil
+ @Published var pendingFlooringPrimary: String? = nil
+ @Published var pendingLandscapingType: String? = nil
+
/// 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 {
@@ -107,11 +124,13 @@ class OnboardingState: ObservableObject {
currentStep = .residenceLocation
}
case .joinResidence:
- currentStep = .subscriptionUpsell
+ completeOnboarding()
case .residenceLocation:
+ currentStep = .homeProfile
+ case .homeProfile:
currentStep = .firstTask
case .firstTask:
- currentStep = .subscriptionUpsell
+ completeOnboarding()
case .subscriptionUpsell:
completeOnboarding()
}
@@ -137,6 +156,7 @@ class OnboardingState: ObservableObject {
regionalTemplates = []
createdResidenceId = nil
userIntent = .unknown
+ resetHomeProfile()
}
/// Reset onboarding state (useful for testing or re-onboarding).
@@ -150,6 +170,25 @@ class OnboardingState: ObservableObject {
createdResidenceId = nil
userIntent = .unknown
currentStep = .welcome
+ resetHomeProfile()
+ }
+
+ /// Reset all home profile fields
+ private func resetHomeProfile() {
+ pendingHeatingType = nil
+ pendingCoolingType = nil
+ pendingWaterHeaterType = nil
+ pendingRoofType = nil
+ pendingHasPool = false
+ pendingHasSprinklerSystem = false
+ pendingHasSeptic = false
+ pendingHasFireplace = false
+ pendingHasGarage = false
+ pendingHasBasement = false
+ pendingHasAttic = false
+ pendingExteriorType = nil
+ pendingFlooringPrimary = nil
+ pendingLandscapingType = nil
}
}
@@ -162,8 +201,9 @@ enum OnboardingStep: Int, CaseIterable {
case verifyEmail = 4
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
+ case homeProfile = 7 // Home systems & features (optional)
+ case firstTask = 8
+ case subscriptionUpsell = 9
var title: String {
switch self {
@@ -181,6 +221,8 @@ enum OnboardingStep: Int, CaseIterable {
return "Join Residence"
case .residenceLocation:
return "Your Location"
+ case .homeProfile:
+ return "Home Profile"
case .firstTask:
return "First Task"
case .subscriptionUpsell:
diff --git a/iosApp/iosApp/Residence/ResidenceViewModel.swift b/iosApp/iosApp/Residence/ResidenceViewModel.swift
index 13db645..8924027 100644
--- a/iosApp/iosApp/Residence/ResidenceViewModel.swift
+++ b/iosApp/iosApp/Residence/ResidenceViewModel.swift
@@ -362,6 +362,20 @@ class ResidenceViewModel: ObservableObject {
isActive: true,
overdueCount: 0,
completionSummary: nil,
+ heatingType: nil,
+ coolingType: nil,
+ waterHeaterType: nil,
+ roofType: nil,
+ hasPool: false,
+ hasSprinklerSystem: false,
+ hasSeptic: false,
+ hasFireplace: false,
+ hasGarage: false,
+ hasBasement: false,
+ hasAttic: false,
+ exteriorType: nil,
+ flooringPrimary: nil,
+ landscapingType: nil,
createdAt: now,
updatedAt: now
)
diff --git a/iosApp/iosApp/ResidenceFormView.swift b/iosApp/iosApp/ResidenceFormView.swift
index 1231a40..d393d11 100644
--- a/iosApp/iosApp/ResidenceFormView.swift
+++ b/iosApp/iosApp/ResidenceFormView.swift
@@ -395,7 +395,21 @@ struct ResidenceFormView: View {
description: description.isEmpty ? nil : description,
purchaseDate: nil,
purchasePrice: nil,
- isPrimary: KotlinBoolean(bool: isPrimary)
+ isPrimary: KotlinBoolean(bool: isPrimary),
+ heatingType: nil,
+ coolingType: nil,
+ waterHeaterType: nil,
+ roofType: nil,
+ hasPool: nil,
+ hasSprinklerSystem: nil,
+ hasSeptic: nil,
+ hasFireplace: nil,
+ hasGarage: nil,
+ hasBasement: nil,
+ hasAttic: nil,
+ exteriorType: nil,
+ flooringPrimary: nil,
+ landscapingType: nil
)
if let residence = existingResidence {
diff --git a/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift b/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift
index 76ba166..c90e650 100644
--- a/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift
+++ b/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift
@@ -318,6 +318,20 @@ private struct PropertyHeaderBackground: View {
isActive: true,
overdueCount: 0,
completionSummary: nil,
+ heatingType: nil,
+ coolingType: nil,
+ waterHeaterType: nil,
+ roofType: nil,
+ hasPool: false,
+ hasSprinklerSystem: false,
+ hasSeptic: false,
+ hasFireplace: false,
+ hasGarage: false,
+ hasBasement: false,
+ hasAttic: false,
+ exteriorType: nil,
+ flooringPrimary: nil,
+ landscapingType: nil,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z"
))
diff --git a/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift b/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift
index 7bb865a..33021ee 100644
--- a/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift
+++ b/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift
@@ -308,6 +308,20 @@ private struct CardBackgroundView: View {
isActive: true,
overdueCount: 2,
completionSummary: nil,
+ heatingType: nil,
+ coolingType: nil,
+ waterHeaterType: nil,
+ roofType: nil,
+ hasPool: false,
+ hasSprinklerSystem: false,
+ hasSeptic: false,
+ hasFireplace: false,
+ hasGarage: false,
+ hasBasement: false,
+ hasAttic: false,
+ exteriorType: nil,
+ flooringPrimary: nil,
+ landscapingType: nil,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z"
),
@@ -341,6 +355,20 @@ private struct CardBackgroundView: View {
isActive: true,
overdueCount: 0,
completionSummary: nil,
+ heatingType: nil,
+ coolingType: nil,
+ waterHeaterType: nil,
+ roofType: nil,
+ hasPool: false,
+ hasSprinklerSystem: false,
+ hasSeptic: false,
+ hasFireplace: false,
+ hasGarage: false,
+ hasBasement: false,
+ hasAttic: false,
+ exteriorType: nil,
+ flooringPrimary: nil,
+ landscapingType: nil,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z"
),