Smart onboarding: home profile, tabbed tasks, free app

New onboarding step: "Tell us about your home" with chip-based pickers
for systems (heating/cooling/water heater), features (pool, fireplace,
garage, etc.), exterior (roof, siding), interior (flooring, landscaping).
All optional, skippable.

Tabbed task selection: "For You" tab shows personalized suggestions
based on home profile, "Browse All" has existing category browser.
Removed 5-task limit — users can add unlimited tasks.

Removed subscription upsell from onboarding flow — app is free.
Fixed picker capsule squishing bug with .fixedSize() modifier.

Both iOS and Compose implementations updated.
This commit is contained in:
Trey T
2026-03-30 09:02:27 -05:00
parent 8f86fa2cd0
commit 4609d5a953
18 changed files with 2293 additions and 266 deletions

View File

@@ -813,6 +813,18 @@
<string name="onboarding_subscription_continue_free">Continue with Free</string> <string name="onboarding_subscription_continue_free">Continue with Free</string>
<string name="onboarding_subscription_trial_terms">7-day free trial, then %1$s. Cancel anytime.</string> <string name="onboarding_subscription_trial_terms">7-day free trial, then %1$s. Cancel anytime.</string>
<!-- Onboarding - Home Profile -->
<string name="onboarding_home_profile_title">Tell us about your home</string>
<string name="onboarding_home_profile_subtitle">All optional — helps us personalize your maintenance plan</string>
<string name="onboarding_home_profile_systems">Systems</string>
<string name="onboarding_home_profile_features">Features</string>
<string name="onboarding_home_profile_exterior">Exterior</string>
<string name="onboarding_home_profile_interior">Interior</string>
<!-- Onboarding - Task Selection Tabs -->
<string name="for_you_tab">For You</string>
<string name="browse_tab">Browse</string>
<!-- Biometric Lock --> <!-- Biometric Lock -->
<string name="biometric_lock_title">App Locked</string> <string name="biometric_lock_title">App Locked</string>
<string name="biometric_lock_description">Authenticate to unlock honeyDue</string> <string name="biometric_lock_description">Authenticate to unlock honeyDue</string>

View File

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

View File

@@ -53,6 +53,20 @@ data class ResidenceResponse(
@SerialName("is_active") val isActive: Boolean = true, @SerialName("is_active") val isActive: Boolean = true,
@SerialName("overdue_count") val overdueCount: Int = 0, @SerialName("overdue_count") val overdueCount: Int = 0,
@SerialName("completion_summary") val completionSummary: CompletionSummary? = null, @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("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String @SerialName("updated_at") val updatedAt: String
) { ) {
@@ -94,7 +108,21 @@ data class ResidenceCreateRequest(
val description: String? = null, val description: String? = null,
@SerialName("purchase_date") val purchaseDate: String? = null, @SerialName("purchase_date") val purchaseDate: String? = null,
@SerialName("purchase_price") val purchasePrice: Double? = 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, val description: String? = null,
@SerialName("purchase_date") val purchaseDate: String? = null, @SerialName("purchase_date") val purchaseDate: String? = null,
@SerialName("purchase_price") val purchasePrice: Double? = 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
) )
/** /**

View File

@@ -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<String>
)
/**
* Response wrapper for task suggestions endpoint.
*/
@Serializable
data class TaskSuggestionsResponse(
val suggestions: List<TaskSuggestionResponse>,
@SerialName("total_count") val totalCount: Int,
@SerialName("profile_completeness") val profileCompleteness: Double
)

View File

@@ -1209,6 +1209,14 @@ object APILayer {
return taskTemplateApi.getTemplatesByRegion(state = state, zip = zip) 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<TaskSuggestionsResponse> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
return taskTemplateApi.getTaskSuggestions(token, residenceId)
}
// ==================== Auth Operations ==================== // ==================== Auth Operations ====================
suspend fun login(request: LoginRequest): ApiResult<AuthResponse> { suspend fun login(request: LoginRequest): ApiResult<AuthResponse> {

View File

@@ -1,6 +1,7 @@
package com.tt.honeyDue.network package com.tt.honeyDue.network
import com.tt.honeyDue.models.TaskTemplate import com.tt.honeyDue.models.TaskTemplate
import com.tt.honeyDue.models.TaskSuggestionsResponse
import com.tt.honeyDue.models.TaskTemplatesGroupedResponse import com.tt.honeyDue.models.TaskTemplatesGroupedResponse
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* 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<TaskSuggestionsResponse> {
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 * Get a single template by ID
*/ */

View File

@@ -23,6 +23,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tt.honeyDue.data.DataManager import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.models.TaskCreateRequest 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.network.ApiResult
import com.tt.honeyDue.ui.theme.* import com.tt.honeyDue.ui.theme.*
import com.tt.honeyDue.viewmodel.OnboardingViewModel import com.tt.honeyDue.viewmodel.OnboardingViewModel
@@ -54,12 +56,22 @@ fun OnboardingFirstTaskContent(
viewModel: OnboardingViewModel, viewModel: OnboardingViewModel,
onTasksAdded: () -> Unit onTasksAdded: () -> Unit
) { ) {
val maxTasksAllowed = 5 var selectedBrowseIds by remember { mutableStateOf(setOf<String>()) }
var selectedTaskIds by remember { mutableStateOf(setOf<String>()) } var selectedSuggestionIds by remember { mutableStateOf(setOf<Int>()) }
var expandedCategoryId by remember { mutableStateOf<String?>(null) } var expandedCategoryId by remember { mutableStateOf<String?>(null) }
var isCreatingTasks by remember { mutableStateOf(false) } var isCreatingTasks by remember { mutableStateOf(false) }
var selectedTabIndex by remember { mutableStateOf(0) }
val createTasksState by viewModel.createTasksState.collectAsState() 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) { LaunchedEffect(createTasksState) {
when (createTasksState) { when (createTasksState) {
@@ -69,7 +81,6 @@ fun OnboardingFirstTaskContent(
} }
is ApiResult.Error -> { is ApiResult.Error -> {
isCreatingTasks = false isCreatingTasks = false
// Still proceed even if task creation fails
onTasksAdded() onTasksAdded()
} }
is ApiResult.Loading -> { is ApiResult.Loading -> {
@@ -148,159 +159,178 @@ fun OnboardingFirstTaskContent(
) )
) )
val allTasks = taskCategories.flatMap { it.tasks } val allBrowseTasks = taskCategories.flatMap { it.tasks }
val selectedCount = selectedTaskIds.size val totalSelectedCount = selectedBrowseIds.size + selectedSuggestionIds.size
val isAtMaxSelection = selectedCount >= maxTasksAllowed val isAtMaxSelection = false // No task selection limit
// Set first category expanded by default // Set first category expanded by default
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
expandedCategoryId = taskCategories.firstOrNull()?.id expandedCategoryId = taskCategories.firstOrNull()?.id
} }
// Determine if suggestions are available
val hasSuggestions = suggestionsState is ApiResult.Success &&
(suggestionsState as? ApiResult.Success<TaskSuggestionsResponse>)?.data?.suggestions?.isNotEmpty() == true
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
LazyColumn( // Header (shared across tabs)
Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md),
contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md) horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// Header OrganicIconContainer(
item { icon = Icons.Default.Celebration,
Column( size = 80.dp,
modifier = Modifier.fillMaxWidth(), iconSize = 40.dp,
horizontalAlignment = Alignment.CenterHorizontally 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 Icon(
OrganicIconContainer( imageVector = Icons.Default.CheckCircleOutline,
icon = Icons.Default.Celebration, contentDescription = null,
size = 80.dp, modifier = Modifier.size(20.dp),
iconSize = 40.dp, tint = MaterialTheme.colorScheme.primary
gradientColors = listOf(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.secondary
),
contentDescription = null
) )
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
Text( Text(
text = stringResource(Res.string.onboarding_tasks_title), text = "$totalSelectedCount task${if (totalSelectedCount == 1) "" else "s"} selected",
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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center color = MaterialTheme.colorScheme.primary
)
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
) )
} }
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( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shadowElevation = 8.dp shadowElevation = 8.dp
@@ -309,30 +339,30 @@ fun OnboardingFirstTaskContent(
modifier = Modifier.padding(OrganicSpacing.lg) modifier = Modifier.padding(OrganicSpacing.lg)
) { ) {
OrganicPrimaryButton( OrganicPrimaryButton(
text = if (selectedCount > 0) { text = if (totalSelectedCount > 0) {
"Add $selectedCount Task${if (selectedCount == 1) "" else "s"} & Continue" "Add $totalSelectedCount Task${if (totalSelectedCount == 1) "" else "s"} & Continue"
} else { } else {
stringResource(Res.string.onboarding_tasks_skip) stringResource(Res.string.onboarding_tasks_skip)
}, },
onClick = { onClick = {
if (selectedTaskIds.isEmpty()) { if (selectedBrowseIds.isEmpty() && selectedSuggestionIds.isEmpty()) {
onTasksAdded() onTasksAdded()
} else { } else {
val residences = DataManager.residences.value val residences = DataManager.residences.value
val residence = residences.firstOrNull() val residence = residences.firstOrNull()
if (residence != null) { if (residence != null) {
val today = DateUtils.getTodayString() val today = DateUtils.getTodayString()
val taskRequests = mutableListOf<TaskCreateRequest>()
val selectedTemplates = allTasks.filter { it.id in selectedTaskIds } // Browse tab selections
val taskRequests = selectedTemplates.map { template -> val selectedBrowseTemplates = allBrowseTasks.filter { it.id in selectedBrowseIds }
taskRequests.addAll(selectedBrowseTemplates.map { template ->
val categoryId = DataManager.taskCategories.value val categoryId = DataManager.taskCategories.value
.find { cat -> cat.name.lowercase() == template.category.lowercase() } .find { cat -> cat.name.lowercase() == template.category.lowercase() }
?.id ?.id
val frequencyId = DataManager.taskFrequencies.value val frequencyId = DataManager.taskFrequencies.value
.find { freq -> freq.name.lowercase() == template.frequency.lowercase() } .find { freq -> freq.name.lowercase() == template.frequency.lowercase() }
?.id ?.id
TaskCreateRequest( TaskCreateRequest(
residenceId = residence.id, residenceId = residence.id,
title = template.title, title = template.title,
@@ -346,7 +376,29 @@ fun OnboardingFirstTaskContent(
estimatedCost = null, estimatedCost = null,
contractorId = null contractorId = null
) )
})
// For You tab selections
val suggestions = (suggestionsState as? ApiResult.Success<TaskSuggestionsResponse>)?.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) viewModel.createTasks(taskRequests)
} else { } else {
onTasksAdded() onTasksAdded()
@@ -363,6 +415,237 @@ fun OnboardingFirstTaskContent(
} }
} }
// ==================== For You Tab ====================
@Composable
private fun ForYouTabContent(
suggestionsState: ApiResult<TaskSuggestionsResponse>,
selectedSuggestionIds: Set<Int>,
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<OnboardingTaskCategory>,
allTasks: List<OnboardingTaskTemplate>,
selectedTaskIds: Set<String>,
expandedCategoryId: String?,
isAtMaxSelection: Boolean,
onToggleExpand: (String) -> Unit,
onToggleTask: (String) -> Unit,
onAddPopular: (Set<String>) -> 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 @Composable
private fun TaskCategorySection( private fun TaskCategorySection(
category: OnboardingTaskCategory, category: OnboardingTaskCategory,

View File

@@ -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<Pair<String, String>>,
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
)
)
}

View File

@@ -126,7 +126,7 @@ fun OnboardingScreen(
OnboardingStep.JOIN_RESIDENCE -> OnboardingJoinResidenceContent( OnboardingStep.JOIN_RESIDENCE -> OnboardingJoinResidenceContent(
viewModel = viewModel, viewModel = viewModel,
onJoined = { viewModel.nextStep() } onJoined = { viewModel.completeOnboarding() }
) )
OnboardingStep.RESIDENCE_LOCATION -> OnboardingLocationContent( OnboardingStep.RESIDENCE_LOCATION -> OnboardingLocationContent(
@@ -138,15 +138,21 @@ fun OnboardingScreen(
onSkip = { viewModel.nextStep() } onSkip = { viewModel.nextStep() }
) )
OnboardingStep.FIRST_TASK -> OnboardingFirstTaskContent( OnboardingStep.HOME_PROFILE -> OnboardingHomeProfileContent(
viewModel = viewModel, viewModel = viewModel,
onTasksAdded = { viewModel.nextStep() } onContinue = { viewModel.nextStep() },
onSkip = { viewModel.skipStep() }
) )
OnboardingStep.SUBSCRIPTION_UPSELL -> OnboardingSubscriptionContent( OnboardingStep.FIRST_TASK -> OnboardingFirstTaskContent(
onSubscribe = { viewModel.completeOnboarding() }, viewModel = viewModel,
onSkip = { viewModel.completeOnboarding() } 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.WELCOME,
OnboardingStep.JOIN_RESIDENCE, OnboardingStep.JOIN_RESIDENCE,
OnboardingStep.RESIDENCE_LOCATION, OnboardingStep.RESIDENCE_LOCATION,
OnboardingStep.HOME_PROFILE,
OnboardingStep.FIRST_TASK, OnboardingStep.FIRST_TASK,
OnboardingStep.SUBSCRIPTION_UPSELL -> false OnboardingStep.SUBSCRIPTION_UPSELL -> false
else -> true else -> true
@@ -173,6 +180,7 @@ private fun OnboardingNavigationBar(
OnboardingStep.VALUE_PROPS, OnboardingStep.VALUE_PROPS,
OnboardingStep.JOIN_RESIDENCE, OnboardingStep.JOIN_RESIDENCE,
OnboardingStep.RESIDENCE_LOCATION, OnboardingStep.RESIDENCE_LOCATION,
OnboardingStep.HOME_PROFILE,
OnboardingStep.FIRST_TASK, OnboardingStep.FIRST_TASK,
OnboardingStep.SUBSCRIPTION_UPSELL -> true OnboardingStep.SUBSCRIPTION_UPSELL -> true
else -> false else -> false
@@ -182,6 +190,7 @@ private fun OnboardingNavigationBar(
OnboardingStep.WELCOME, OnboardingStep.WELCOME,
OnboardingStep.JOIN_RESIDENCE, OnboardingStep.JOIN_RESIDENCE,
OnboardingStep.RESIDENCE_LOCATION, OnboardingStep.RESIDENCE_LOCATION,
OnboardingStep.HOME_PROFILE,
OnboardingStep.FIRST_TASK, OnboardingStep.FIRST_TASK,
OnboardingStep.SUBSCRIPTION_UPSELL -> false OnboardingStep.SUBSCRIPTION_UPSELL -> false
else -> true else -> true
@@ -195,6 +204,7 @@ private fun OnboardingNavigationBar(
OnboardingStep.VERIFY_EMAIL -> 4 OnboardingStep.VERIFY_EMAIL -> 4
OnboardingStep.JOIN_RESIDENCE -> 4 OnboardingStep.JOIN_RESIDENCE -> 4
OnboardingStep.RESIDENCE_LOCATION -> 4 OnboardingStep.RESIDENCE_LOCATION -> 4
OnboardingStep.HOME_PROFILE -> 4
OnboardingStep.FIRST_TASK -> 4 OnboardingStep.FIRST_TASK -> 4
OnboardingStep.SUBSCRIPTION_UPSELL -> 4 OnboardingStep.SUBSCRIPTION_UPSELL -> 4
} }

View File

@@ -8,6 +8,7 @@ import com.tt.honeyDue.models.LoginRequest
import com.tt.honeyDue.models.RegisterRequest import com.tt.honeyDue.models.RegisterRequest
import com.tt.honeyDue.models.ResidenceCreateRequest import com.tt.honeyDue.models.ResidenceCreateRequest
import com.tt.honeyDue.models.TaskCreateRequest import com.tt.honeyDue.models.TaskCreateRequest
import com.tt.honeyDue.models.TaskSuggestionsResponse
import com.tt.honeyDue.models.TaskTemplate import com.tt.honeyDue.models.TaskTemplate
import com.tt.honeyDue.models.VerifyEmailRequest import com.tt.honeyDue.models.VerifyEmailRequest
import com.tt.honeyDue.network.ApiResult import com.tt.honeyDue.network.ApiResult
@@ -37,6 +38,7 @@ enum class OnboardingStep {
VERIFY_EMAIL, VERIFY_EMAIL,
JOIN_RESIDENCE, JOIN_RESIDENCE,
RESIDENCE_LOCATION, RESIDENCE_LOCATION,
HOME_PROFILE,
FIRST_TASK, FIRST_TASK,
SUBSCRIPTION_UPSELL SUBSCRIPTION_UPSELL
} }
@@ -90,6 +92,53 @@ class OnboardingViewModel : ViewModel() {
private val _postalCode = MutableStateFlow("") private val _postalCode = MutableStateFlow("")
val postalCode: StateFlow<String> = _postalCode val postalCode: StateFlow<String> = _postalCode
// Home profile fields
private val _heatingType = MutableStateFlow<String?>(null)
val heatingType: StateFlow<String?> = _heatingType
private val _coolingType = MutableStateFlow<String?>(null)
val coolingType: StateFlow<String?> = _coolingType
private val _waterHeaterType = MutableStateFlow<String?>(null)
val waterHeaterType: StateFlow<String?> = _waterHeaterType
private val _roofType = MutableStateFlow<String?>(null)
val roofType: StateFlow<String?> = _roofType
private val _hasPool = MutableStateFlow(false)
val hasPool: StateFlow<Boolean> = _hasPool
private val _hasSprinklerSystem = MutableStateFlow(false)
val hasSprinklerSystem: StateFlow<Boolean> = _hasSprinklerSystem
private val _hasSeptic = MutableStateFlow(false)
val hasSeptic: StateFlow<Boolean> = _hasSeptic
private val _hasFireplace = MutableStateFlow(false)
val hasFireplace: StateFlow<Boolean> = _hasFireplace
private val _hasGarage = MutableStateFlow(false)
val hasGarage: StateFlow<Boolean> = _hasGarage
private val _hasBasement = MutableStateFlow(false)
val hasBasement: StateFlow<Boolean> = _hasBasement
private val _hasAttic = MutableStateFlow(false)
val hasAttic: StateFlow<Boolean> = _hasAttic
private val _exteriorType = MutableStateFlow<String?>(null)
val exteriorType: StateFlow<String?> = _exteriorType
private val _flooringPrimary = MutableStateFlow<String?>(null)
val flooringPrimary: StateFlow<String?> = _flooringPrimary
private val _landscapingType = MutableStateFlow<String?>(null)
val landscapingType: StateFlow<String?> = _landscapingType
// Task suggestions state
private val _suggestionsState = MutableStateFlow<ApiResult<TaskSuggestionsResponse>>(ApiResult.Idle)
val suggestionsState: StateFlow<ApiResult<TaskSuggestionsResponse>> = _suggestionsState
// Whether onboarding is complete // Whether onboarding is complete
private val _isComplete = MutableStateFlow(false) private val _isComplete = MutableStateFlow(false)
val isComplete: StateFlow<Boolean> = _isComplete val isComplete: StateFlow<Boolean> = _isComplete
@@ -106,6 +155,32 @@ class OnboardingViewModel : ViewModel() {
_shareCode.value = code _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 * Move to the next step in the flow
* Flow: Welcome → Features → Name Residence → Create Account → Verify → Tasks → Upsell * Flow: Welcome → Features → Name Residence → Create Account → Verify → Tasks → Upsell
@@ -129,9 +204,16 @@ class OnboardingViewModel : ViewModel() {
OnboardingStep.RESIDENCE_LOCATION OnboardingStep.RESIDENCE_LOCATION
} }
} }
OnboardingStep.JOIN_RESIDENCE -> OnboardingStep.SUBSCRIPTION_UPSELL OnboardingStep.JOIN_RESIDENCE -> {
OnboardingStep.RESIDENCE_LOCATION -> OnboardingStep.FIRST_TASK completeOnboarding()
OnboardingStep.FIRST_TASK -> OnboardingStep.SUBSCRIPTION_UPSELL 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 -> { OnboardingStep.SUBSCRIPTION_UPSELL -> {
completeOnboarding() completeOnboarding()
OnboardingStep.SUBSCRIPTION_UPSELL OnboardingStep.SUBSCRIPTION_UPSELL
@@ -171,9 +253,10 @@ class OnboardingViewModel : ViewModel() {
fun skipStep() { fun skipStep() {
when (_currentStep.value) { when (_currentStep.value) {
OnboardingStep.VALUE_PROPS, OnboardingStep.VALUE_PROPS,
OnboardingStep.JOIN_RESIDENCE,
OnboardingStep.RESIDENCE_LOCATION, OnboardingStep.RESIDENCE_LOCATION,
OnboardingStep.FIRST_TASK -> nextStep() OnboardingStep.HOME_PROFILE -> nextStep()
OnboardingStep.JOIN_RESIDENCE,
OnboardingStep.FIRST_TASK,
OnboardingStep.SUBSCRIPTION_UPSELL -> completeOnboarding() OnboardingStep.SUBSCRIPTION_UPSELL -> completeOnboarding()
else -> {} else -> {}
} }
@@ -272,7 +355,21 @@ class OnboardingViewModel : ViewModel() {
description = null, description = null,
purchaseDate = null, purchaseDate = null,
purchasePrice = 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 _createTasksState.value = ApiResult.Idle
_regionalTemplates.value = ApiResult.Idle _regionalTemplates.value = ApiResult.Idle
_postalCode.value = "" _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 _isComplete.value = false
} }

View File

@@ -69,6 +69,15 @@ struct OnboardingCoordinator: View {
isCreatingResidence = true 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( let request = ResidenceCreateRequest(
name: onboardingState.pendingResidenceName, name: onboardingState.pendingResidenceName,
propertyTypeId: nil, propertyTypeId: nil,
@@ -86,7 +95,21 @@ struct OnboardingCoordinator: View {
description: nil, description: nil,
purchaseDate: nil, purchaseDate: nil,
purchasePrice: 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 residenceViewModel.createResidence(request: request) { (residence: ResidenceResponse?) in
@@ -103,7 +126,7 @@ struct OnboardingCoordinator: View {
} }
/// Current step index for progress indicator (0-based) /// 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 { private var currentProgressStep: Int {
switch onboardingState.currentStep { switch onboardingState.currentStep {
case .welcome: return 0 case .welcome: return 0
@@ -113,6 +136,7 @@ struct OnboardingCoordinator: View {
case .verifyEmail: return 4 case .verifyEmail: return 4
case .joinResidence: return 4 case .joinResidence: return 4
case .residenceLocation: return 4 case .residenceLocation: return 4
case .homeProfile: return 4
case .firstTask: return 4 case .firstTask: return 4
case .subscriptionUpsell: return 4 case .subscriptionUpsell: return 4
} }
@@ -121,7 +145,7 @@ struct OnboardingCoordinator: View {
/// Whether to show the back button /// Whether to show the back button
private var showBackButton: Bool { private var showBackButton: Bool {
switch onboardingState.currentStep { switch onboardingState.currentStep {
case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell: case .welcome, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
return false return false
default: default:
return true return true
@@ -131,7 +155,7 @@ struct OnboardingCoordinator: View {
/// Whether to show the skip button /// Whether to show the skip button
private var showSkipButton: Bool { private var showSkipButton: Bool {
switch onboardingState.currentStep { switch onboardingState.currentStep {
case .valueProps, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell: case .valueProps, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
return true return true
default: default:
return false return false
@@ -141,7 +165,7 @@ struct OnboardingCoordinator: View {
/// Whether to show the progress indicator /// Whether to show the progress indicator
private var showProgressIndicator: Bool { private var showProgressIndicator: Bool {
switch onboardingState.currentStep { switch onboardingState.currentStep {
case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell: case .welcome, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
return false return false
default: default:
return true return true
@@ -174,11 +198,15 @@ struct OnboardingCoordinator: View {
case .valueProps: case .valueProps:
goForward() goForward()
case .residenceLocation: 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) createResidenceIfNeeded(thenNavigateTo: .firstTask)
case .joinResidence, .firstTask: case .joinResidence:
goForward() onboardingState.completeOnboarding()
case .subscriptionUpsell: onComplete()
case .firstTask, .subscriptionUpsell:
onboardingState.completeOnboarding() onboardingState.completeOnboarding()
onComplete() onComplete()
default: default:
@@ -301,7 +329,8 @@ struct OnboardingCoordinator: View {
case .joinResidence: case .joinResidence:
OnboardingJoinResidenceContent( OnboardingJoinResidenceContent(
onJoined: { onJoined: {
goForward() onboardingState.completeOnboarding()
onComplete()
} }
) )
.transition(navigationTransition) .transition(navigationTransition)
@@ -309,9 +338,21 @@ struct OnboardingCoordinator: View {
case .residenceLocation: case .residenceLocation:
OnboardingLocationContent( OnboardingLocationContent(
onLocationDetected: { zip in onLocationDetected: { zip in
// Load regional templates in background while creating residence // Load regional templates in background
onboardingState.loadRegionalTemplates(zip: zip) 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) createResidenceIfNeeded(thenNavigateTo: .firstTask)
}, },
onSkip: { onSkip: {
@@ -324,20 +365,21 @@ struct OnboardingCoordinator: View {
OnboardingFirstTaskContent( OnboardingFirstTaskContent(
residenceName: onboardingState.pendingResidenceName, residenceName: onboardingState.pendingResidenceName,
onTaskAdded: { onTaskAdded: {
goForward()
}
)
.transition(navigationTransition)
case .subscriptionUpsell:
OnboardingSubscriptionContent(
onSubscribe: {
// Handle subscription flow
onboardingState.completeOnboarding() onboardingState.completeOnboarding()
onComplete() onComplete()
} }
) )
.transition(navigationTransition) .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) .animation(.easeInOut(duration: 0.3), value: onboardingState.currentStep)

View File

@@ -1,6 +1,12 @@
import SwiftUI import SwiftUI
import ComposeApp 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) /// Screen 6: First task prompt with suggested templates - Content only (no navigation bar)
struct OnboardingFirstTaskContent: View { struct OnboardingFirstTaskContent: View {
var residenceName: String var residenceName: String
@@ -13,10 +19,12 @@ struct OnboardingFirstTaskContent: View {
@State private var isCreatingTasks = false @State private var isCreatingTasks = false
@State private var expandedCategories: Set<String> = [] @State private var expandedCategories: Set<String> = []
@State private var isAnimating = false @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 @Environment(\.colorScheme) var colorScheme
/// Maximum tasks allowed for free tier (matches API TierLimits) // No task selection limit users can add as many as they want
private let maxTasksAllowed = 5
/// Category colors by name (used for both API and fallback templates) /// Category colors by name (used for both API and fallback templates)
private static let categoryColors: [String: Color] = [ private static let categoryColors: [String: Color] = [
@@ -173,7 +181,7 @@ struct OnboardingFirstTaskContent: View {
} }
private var isAtMaxSelection: Bool { private var isAtMaxSelection: Bool {
selectedTasks.count >= maxTasksAllowed false
} }
var body: some View { var body: some View {
@@ -304,88 +312,107 @@ struct OnboardingFirstTaskContent: View {
Image(systemName: isAtMaxSelection ? "checkmark.seal.fill" : "checkmark.circle.fill") Image(systemName: isAtMaxSelection ? "checkmark.seal.fill" : "checkmark.circle.fill")
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary) .foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
Text("\(selectedCount)/\(maxTasksAllowed) tasks selected") Text("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected")
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary) .foregroundColor(Color.appPrimary)
} }
.padding(.horizontal, 18) .padding(.horizontal, 18)
.padding(.vertical, 10) .padding(.vertical, 10)
.background((isAtMaxSelection ? Color.appAccent : Color.appPrimary).opacity(0.1)) .background(Color.appPrimary.opacity(0.1))
.clipShape(Capsule()) .clipShape(Capsule())
.animation(.spring(response: 0.3), value: selectedCount) .animation(.spring(response: 0.3), value: selectedCount)
.accessibilityLabel("\(selectedCount) of \(maxTasksAllowed) tasks selected") .accessibilityLabel("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected")
// Task categories // Tab bar
VStack(spacing: 12) { OnboardingTaskTabBar(selectedTab: $selectedTab)
ForEach(taskCategories) { category in .padding(.horizontal, OrganicSpacing.comfortable)
OrganicTaskCategorySection(
category: category, // Tab content
selectedTasks: $selectedTasks, switch selectedTab {
isExpanded: expandedCategories.contains(category.name), case .forYou:
isAtMaxSelection: isAtMaxSelection, // For You tab personalized suggestions
onToggleExpand: { ForYouTasksTab(
let isExpanding = !expandedCategories.contains(category.name) forYouTemplates: forYouTemplates,
withAnimation(.spring(response: 0.3)) { isLoading: isLoadingSuggestions,
if expandedCategories.contains(category.name) { selectedTasks: $selectedTasks,
expandedCategories.remove(category.name) isAtMaxSelection: isAtMaxSelection,
} else { hasResidence: onboardingState.createdResidenceId != nil
expandedCategories.insert(category.name) )
.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 {
if isExpanding { DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { withAnimation {
withAnimation { proxy.scrollTo(category.name, anchor: .top)
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 .padding(.bottom, 140) // Space for button
} }
@@ -448,6 +475,8 @@ struct OnboardingFirstTaskContent: View {
if let first = taskCategories.first?.name { if let first = taskCategories.first?.name {
expandedCategories.insert(first) expandedCategories.insert(first)
} }
// Build "For You" suggestions based on home profile
buildForYouSuggestions()
} }
.onDisappear { .onDisappear {
isAnimating = false isAnimating = false
@@ -457,11 +486,9 @@ struct OnboardingFirstTaskContent: View {
private func selectPopularTasks() { private func selectPopularTasks() {
withAnimation(.spring(response: 0.3)) { withAnimation(.spring(response: 0.3)) {
if !onboardingState.regionalTemplates.isEmpty { 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 { for task in allTasks {
if selectedTasks.count < maxTasksAllowed { selectedTasks.insert(task.id)
selectedTasks.insert(task.id)
}
} }
} else { } else {
// Fallback: select hardcoded popular tasks // Fallback: select hardcoded popular tasks
@@ -473,14 +500,164 @@ struct OnboardingFirstTaskContent: View {
"Clean Refrigerator Coils" "Clean Refrigerator Coils"
] ]
for task in allTasks where popularTaskTitles.contains(task.title) { 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<String>()
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() { private func addSelectedTasks() {
// If no tasks selected, just skip // If no tasks selected, just skip
if selectedTasks.isEmpty { if selectedTasks.isEmpty {
@@ -497,9 +674,14 @@ struct OnboardingFirstTaskContent: View {
isCreatingTasks = true 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<String>()
let uniqueTemplates = selectedTemplates.filter { seenTitles.insert($0.title).inserted }
var completedCount = 0 var completedCount = 0
let totalCount = selectedTemplates.count let totalCount = uniqueTemplates.count
// Safety: if no templates matched (shouldn't happen), skip // Safety: if no templates matched (shouldn't happen), skip
if totalCount == 0 { if totalCount == 0 {
@@ -516,7 +698,7 @@ struct OnboardingFirstTaskContent: View {
print("🏠 ONBOARDING: Creating \(totalCount) tasks for residence \(residenceId)") print("🏠 ONBOARDING: Creating \(totalCount) tasks for residence \(residenceId)")
for template in selectedTemplates { for template in uniqueTemplates {
// Look up category ID from DataManager // Look up category ID from DataManager
let categoryId: Int32? = { let categoryId: Int32? = {
return dataManager.taskCategories.first { $0.name.caseInsensitiveCompare(template.category) == .orderedSame }?.id return dataManager.taskCategories.first { $0.name.caseInsensitiveCompare(template.category) == .orderedSame }?.id
@@ -760,6 +942,230 @@ struct OnboardingTaskTemplate: Identifiable {
let color: Color 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<UUID>
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) // MARK: - Legacy wrapper with navigation bar (for backwards compatibility)
struct OnboardingFirstTaskView: View { struct OnboardingFirstTaskView: View {

View File

@@ -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<Content: View>: 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: {}
)
}

View File

@@ -45,6 +45,23 @@ class OnboardingState: ObservableObject {
/// Whether regional templates are currently loading /// Whether regional templates are currently loading
@Published var isLoadingTemplates: Bool = false @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). /// The user's selected intent (start fresh or join existing).
/// Reads/writes the persisted @AppStorage value and notifies SwiftUI of the change. /// Reads/writes the persisted @AppStorage value and notifies SwiftUI of the change.
var userIntent: OnboardingIntent { var userIntent: OnboardingIntent {
@@ -107,11 +124,13 @@ class OnboardingState: ObservableObject {
currentStep = .residenceLocation currentStep = .residenceLocation
} }
case .joinResidence: case .joinResidence:
currentStep = .subscriptionUpsell completeOnboarding()
case .residenceLocation: case .residenceLocation:
currentStep = .homeProfile
case .homeProfile:
currentStep = .firstTask currentStep = .firstTask
case .firstTask: case .firstTask:
currentStep = .subscriptionUpsell completeOnboarding()
case .subscriptionUpsell: case .subscriptionUpsell:
completeOnboarding() completeOnboarding()
} }
@@ -137,6 +156,7 @@ class OnboardingState: ObservableObject {
regionalTemplates = [] regionalTemplates = []
createdResidenceId = nil createdResidenceId = nil
userIntent = .unknown userIntent = .unknown
resetHomeProfile()
} }
/// Reset onboarding state (useful for testing or re-onboarding). /// Reset onboarding state (useful for testing or re-onboarding).
@@ -150,6 +170,25 @@ class OnboardingState: ObservableObject {
createdResidenceId = nil createdResidenceId = nil
userIntent = .unknown userIntent = .unknown
currentStep = .welcome 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 verifyEmail = 4
case joinResidence = 5 // Only for users joining with a code case joinResidence = 5 // Only for users joining with a code
case residenceLocation = 6 // ZIP code entry for regional templates case residenceLocation = 6 // ZIP code entry for regional templates
case firstTask = 7 case homeProfile = 7 // Home systems & features (optional)
case subscriptionUpsell = 8 case firstTask = 8
case subscriptionUpsell = 9
var title: String { var title: String {
switch self { switch self {
@@ -181,6 +221,8 @@ enum OnboardingStep: Int, CaseIterable {
return "Join Residence" return "Join Residence"
case .residenceLocation: case .residenceLocation:
return "Your Location" return "Your Location"
case .homeProfile:
return "Home Profile"
case .firstTask: case .firstTask:
return "First Task" return "First Task"
case .subscriptionUpsell: case .subscriptionUpsell:

View File

@@ -362,6 +362,20 @@ class ResidenceViewModel: ObservableObject {
isActive: true, isActive: true,
overdueCount: 0, overdueCount: 0,
completionSummary: nil, 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, createdAt: now,
updatedAt: now updatedAt: now
) )

View File

@@ -395,7 +395,21 @@ struct ResidenceFormView: View {
description: description.isEmpty ? nil : description, description: description.isEmpty ? nil : description,
purchaseDate: nil, purchaseDate: nil,
purchasePrice: 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 { if let residence = existingResidence {

View File

@@ -318,6 +318,20 @@ private struct PropertyHeaderBackground: View {
isActive: true, isActive: true,
overdueCount: 0, overdueCount: 0,
completionSummary: nil, 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", createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z" updatedAt: "2024-01-01T00:00:00Z"
)) ))

View File

@@ -308,6 +308,20 @@ private struct CardBackgroundView: View {
isActive: true, isActive: true,
overdueCount: 2, overdueCount: 2,
completionSummary: nil, 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", createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z" updatedAt: "2024-01-01T00:00:00Z"
), ),
@@ -341,6 +355,20 @@ private struct CardBackgroundView: View {
isActive: true, isActive: true,
overdueCount: 0, overdueCount: 0,
completionSummary: nil, 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", createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z" updatedAt: "2024-01-01T00:00:00Z"
), ),