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" ),