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:
@@ -813,6 +813,18 @@
|
||||
<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>
|
||||
|
||||
<!-- 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 -->
|
||||
<string name="biometric_lock_title">App Locked</string>
|
||||
<string name="biometric_lock_description">Authenticate to unlock honeyDue</string>
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
@@ -53,6 +53,20 @@ data class ResidenceResponse(
|
||||
@SerialName("is_active") val isActive: Boolean = true,
|
||||
@SerialName("overdue_count") val overdueCount: Int = 0,
|
||||
@SerialName("completion_summary") val completionSummary: CompletionSummary? = null,
|
||||
@SerialName("heating_type") val heatingType: String? = null,
|
||||
@SerialName("cooling_type") val coolingType: String? = null,
|
||||
@SerialName("water_heater_type") val waterHeaterType: String? = null,
|
||||
@SerialName("roof_type") val roofType: String? = null,
|
||||
@SerialName("has_pool") val hasPool: Boolean = false,
|
||||
@SerialName("has_sprinkler_system") val hasSprinklerSystem: Boolean = false,
|
||||
@SerialName("has_septic") val hasSeptic: Boolean = false,
|
||||
@SerialName("has_fireplace") val hasFireplace: Boolean = false,
|
||||
@SerialName("has_garage") val hasGarage: Boolean = false,
|
||||
@SerialName("has_basement") val hasBasement: Boolean = false,
|
||||
@SerialName("has_attic") val hasAttic: Boolean = false,
|
||||
@SerialName("exterior_type") val exteriorType: String? = null,
|
||||
@SerialName("flooring_primary") val flooringPrimary: String? = null,
|
||||
@SerialName("landscaping_type") val landscapingType: String? = null,
|
||||
@SerialName("created_at") val createdAt: String,
|
||||
@SerialName("updated_at") val updatedAt: String
|
||||
) {
|
||||
@@ -94,7 +108,21 @@ data class ResidenceCreateRequest(
|
||||
val description: String? = null,
|
||||
@SerialName("purchase_date") val purchaseDate: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: Double? = null,
|
||||
@SerialName("is_primary") val isPrimary: Boolean? = null
|
||||
@SerialName("is_primary") val isPrimary: Boolean? = null,
|
||||
@SerialName("heating_type") val heatingType: String? = null,
|
||||
@SerialName("cooling_type") val coolingType: String? = null,
|
||||
@SerialName("water_heater_type") val waterHeaterType: String? = null,
|
||||
@SerialName("roof_type") val roofType: String? = null,
|
||||
@SerialName("has_pool") val hasPool: Boolean? = null,
|
||||
@SerialName("has_sprinkler_system") val hasSprinklerSystem: Boolean? = null,
|
||||
@SerialName("has_septic") val hasSeptic: Boolean? = null,
|
||||
@SerialName("has_fireplace") val hasFireplace: Boolean? = null,
|
||||
@SerialName("has_garage") val hasGarage: Boolean? = null,
|
||||
@SerialName("has_basement") val hasBasement: Boolean? = null,
|
||||
@SerialName("has_attic") val hasAttic: Boolean? = null,
|
||||
@SerialName("exterior_type") val exteriorType: String? = null,
|
||||
@SerialName("flooring_primary") val flooringPrimary: String? = null,
|
||||
@SerialName("landscaping_type") val landscapingType: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -118,7 +146,21 @@ data class ResidenceUpdateRequest(
|
||||
val description: String? = null,
|
||||
@SerialName("purchase_date") val purchaseDate: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: Double? = null,
|
||||
@SerialName("is_primary") val isPrimary: Boolean? = null
|
||||
@SerialName("is_primary") val isPrimary: Boolean? = null,
|
||||
@SerialName("heating_type") val heatingType: String? = null,
|
||||
@SerialName("cooling_type") val coolingType: String? = null,
|
||||
@SerialName("water_heater_type") val waterHeaterType: String? = null,
|
||||
@SerialName("roof_type") val roofType: String? = null,
|
||||
@SerialName("has_pool") val hasPool: Boolean? = null,
|
||||
@SerialName("has_sprinkler_system") val hasSprinklerSystem: Boolean? = null,
|
||||
@SerialName("has_septic") val hasSeptic: Boolean? = null,
|
||||
@SerialName("has_fireplace") val hasFireplace: Boolean? = null,
|
||||
@SerialName("has_garage") val hasGarage: Boolean? = null,
|
||||
@SerialName("has_basement") val hasBasement: Boolean? = null,
|
||||
@SerialName("has_attic") val hasAttic: Boolean? = null,
|
||||
@SerialName("exterior_type") val exteriorType: String? = null,
|
||||
@SerialName("flooring_primary") val flooringPrimary: String? = null,
|
||||
@SerialName("landscaping_type") val landscapingType: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -1209,6 +1209,14 @@ object APILayer {
|
||||
return taskTemplateApi.getTemplatesByRegion(state = state, zip = zip)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get personalized task suggestions for a residence based on its home profile.
|
||||
*/
|
||||
suspend fun getTaskSuggestions(residenceId: Int): ApiResult<TaskSuggestionsResponse> {
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
return taskTemplateApi.getTaskSuggestions(token, residenceId)
|
||||
}
|
||||
|
||||
// ==================== Auth Operations ====================
|
||||
|
||||
suspend fun login(request: LoginRequest): ApiResult<AuthResponse> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import com.tt.honeyDue.models.TaskTemplate
|
||||
import com.tt.honeyDue.models.TaskSuggestionsResponse
|
||||
import com.tt.honeyDue.models.TaskTemplatesGroupedResponse
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
@@ -105,6 +106,27 @@ class TaskTemplateApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get personalized task suggestions for a residence based on its home profile.
|
||||
* Requires authentication.
|
||||
*/
|
||||
suspend fun getTaskSuggestions(token: String, residenceId: Int): ApiResult<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
|
||||
*/
|
||||
|
||||
@@ -23,6 +23,8 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.models.TaskCreateRequest
|
||||
import com.tt.honeyDue.models.TaskSuggestionResponse
|
||||
import com.tt.honeyDue.models.TaskSuggestionsResponse
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.ui.theme.*
|
||||
import com.tt.honeyDue.viewmodel.OnboardingViewModel
|
||||
@@ -54,12 +56,22 @@ fun OnboardingFirstTaskContent(
|
||||
viewModel: OnboardingViewModel,
|
||||
onTasksAdded: () -> Unit
|
||||
) {
|
||||
val maxTasksAllowed = 5
|
||||
var selectedTaskIds by remember { mutableStateOf(setOf<String>()) }
|
||||
var selectedBrowseIds by remember { mutableStateOf(setOf<String>()) }
|
||||
var selectedSuggestionIds by remember { mutableStateOf(setOf<Int>()) }
|
||||
var expandedCategoryId by remember { mutableStateOf<String?>(null) }
|
||||
var isCreatingTasks by remember { mutableStateOf(false) }
|
||||
var selectedTabIndex by remember { mutableStateOf(0) }
|
||||
|
||||
val createTasksState by viewModel.createTasksState.collectAsState()
|
||||
val suggestionsState by viewModel.suggestionsState.collectAsState()
|
||||
|
||||
// Load suggestions on mount if a residence exists
|
||||
LaunchedEffect(Unit) {
|
||||
val residence = DataManager.residences.value.firstOrNull()
|
||||
if (residence != null) {
|
||||
viewModel.loadSuggestions(residence.id)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(createTasksState) {
|
||||
when (createTasksState) {
|
||||
@@ -69,7 +81,6 @@ fun OnboardingFirstTaskContent(
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
isCreatingTasks = false
|
||||
// Still proceed even if task creation fails
|
||||
onTasksAdded()
|
||||
}
|
||||
is ApiResult.Loading -> {
|
||||
@@ -148,159 +159,178 @@ fun OnboardingFirstTaskContent(
|
||||
)
|
||||
)
|
||||
|
||||
val allTasks = taskCategories.flatMap { it.tasks }
|
||||
val selectedCount = selectedTaskIds.size
|
||||
val isAtMaxSelection = selectedCount >= maxTasksAllowed
|
||||
val allBrowseTasks = taskCategories.flatMap { it.tasks }
|
||||
val totalSelectedCount = selectedBrowseIds.size + selectedSuggestionIds.size
|
||||
val isAtMaxSelection = false // No task selection limit
|
||||
|
||||
// Set first category expanded by default
|
||||
LaunchedEffect(Unit) {
|
||||
expandedCategoryId = taskCategories.firstOrNull()?.id
|
||||
}
|
||||
|
||||
// Determine if suggestions are available
|
||||
val hasSuggestions = suggestionsState is ApiResult.Success &&
|
||||
(suggestionsState as? ApiResult.Success<TaskSuggestionsResponse>)?.data?.suggestions?.isNotEmpty() == true
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
// Header (shared across tabs)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
|
||||
.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Header
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
OrganicIconContainer(
|
||||
icon = Icons.Default.Celebration,
|
||||
size = 80.dp,
|
||||
iconSize = 40.dp,
|
||||
gradientColors = listOf(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
MaterialTheme.colorScheme.secondary
|
||||
),
|
||||
contentDescription = null
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.onboarding_tasks_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.onboarding_tasks_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
|
||||
|
||||
// Selection counter
|
||||
Surface(
|
||||
shape = RoundedCornerShape(OrganicRadius.xl),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.sm),
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Celebration icon using OrganicIconContainer
|
||||
OrganicIconContainer(
|
||||
icon = Icons.Default.Celebration,
|
||||
size = 80.dp,
|
||||
iconSize = 40.dp,
|
||||
gradientColors = listOf(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
MaterialTheme.colorScheme.secondary
|
||||
),
|
||||
contentDescription = null
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircleOutline,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.onboarding_tasks_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.onboarding_tasks_subtitle),
|
||||
text = "$totalSelectedCount task${if (totalSelectedCount == 1) "" else "s"} selected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
|
||||
|
||||
// Selection counter
|
||||
Surface(
|
||||
shape = RoundedCornerShape(OrganicRadius.xl),
|
||||
color = if (isAtMaxSelection) {
|
||||
MaterialTheme.colorScheme.tertiary.copy(alpha = 0.1f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.sm),
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isAtMaxSelection) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (isAtMaxSelection) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "$selectedCount/$maxTasksAllowed tasks selected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isAtMaxSelection) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
|
||||
}
|
||||
}
|
||||
|
||||
// Task categories
|
||||
items(taskCategories) { category ->
|
||||
TaskCategorySection(
|
||||
category = category,
|
||||
selectedTaskIds = selectedTaskIds,
|
||||
isExpanded = expandedCategoryId == category.id,
|
||||
isAtMaxSelection = isAtMaxSelection,
|
||||
onToggleExpand = {
|
||||
expandedCategoryId = if (expandedCategoryId == category.id) null else category.id
|
||||
},
|
||||
onToggleTask = { taskId ->
|
||||
selectedTaskIds = if (taskId in selectedTaskIds) {
|
||||
selectedTaskIds - taskId
|
||||
} else if (!isAtMaxSelection) {
|
||||
selectedTaskIds + taskId
|
||||
} else {
|
||||
selectedTaskIds
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.md))
|
||||
}
|
||||
|
||||
// Add popular tasks button
|
||||
item {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val popularTitles = listOf(
|
||||
"Change HVAC Filter",
|
||||
"Test Smoke Detectors",
|
||||
"Check for Leaks",
|
||||
"Clean Gutters",
|
||||
"Clean Refrigerator Coils"
|
||||
)
|
||||
val popularIds = allTasks
|
||||
.filter { it.title in popularTitles }
|
||||
.take(maxTasksAllowed)
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
selectedTaskIds = popularIds
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
shape = RoundedCornerShape(OrganicRadius.lg),
|
||||
border = ButtonDefaults.outlinedButtonBorder.copy(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.AutoAwesome, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
|
||||
Text(
|
||||
text = stringResource(Res.string.onboarding_tasks_add_popular),
|
||||
fontWeight = FontWeight.Medium
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(100.dp)) // Space for bottom button
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom action area
|
||||
// Tab row (only show if we have suggestions)
|
||||
if (hasSuggestions || suggestionsState is ApiResult.Loading) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Tab(
|
||||
selected = selectedTabIndex == 0,
|
||||
onClick = { selectedTabIndex = 0 },
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(Res.string.for_you_tab),
|
||||
fontWeight = if (selectedTabIndex == 0) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.AutoAwesome,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
Tab(
|
||||
selected = selectedTabIndex == 1,
|
||||
onClick = { selectedTabIndex = 1 },
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(Res.string.browse_tab),
|
||||
fontWeight = if (selectedTabIndex == 1) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.ViewList,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Tab content
|
||||
when {
|
||||
(hasSuggestions || suggestionsState is ApiResult.Loading) && selectedTabIndex == 0 -> {
|
||||
// For You tab
|
||||
ForYouTabContent(
|
||||
suggestionsState = suggestionsState,
|
||||
selectedSuggestionIds = selectedSuggestionIds,
|
||||
isAtMaxSelection = isAtMaxSelection,
|
||||
onToggleSuggestion = { templateId ->
|
||||
selectedSuggestionIds = if (templateId in selectedSuggestionIds) {
|
||||
selectedSuggestionIds - templateId
|
||||
} else if (!isAtMaxSelection) {
|
||||
selectedSuggestionIds + templateId
|
||||
} else {
|
||||
selectedSuggestionIds
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
// Browse tab (or default when no suggestions)
|
||||
BrowseTabContent(
|
||||
taskCategories = taskCategories,
|
||||
allTasks = allBrowseTasks,
|
||||
selectedTaskIds = selectedBrowseIds,
|
||||
expandedCategoryId = expandedCategoryId,
|
||||
isAtMaxSelection = isAtMaxSelection,
|
||||
onToggleExpand = { catId ->
|
||||
expandedCategoryId = if (expandedCategoryId == catId) null else catId
|
||||
},
|
||||
onToggleTask = { taskId ->
|
||||
selectedBrowseIds = if (taskId in selectedBrowseIds) {
|
||||
selectedBrowseIds - taskId
|
||||
} else if (!isAtMaxSelection) {
|
||||
selectedBrowseIds + taskId
|
||||
} else {
|
||||
selectedBrowseIds
|
||||
}
|
||||
},
|
||||
onAddPopular = { popularIds ->
|
||||
selectedBrowseIds = popularIds
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom action area (shared)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shadowElevation = 8.dp
|
||||
@@ -309,30 +339,30 @@ fun OnboardingFirstTaskContent(
|
||||
modifier = Modifier.padding(OrganicSpacing.lg)
|
||||
) {
|
||||
OrganicPrimaryButton(
|
||||
text = if (selectedCount > 0) {
|
||||
"Add $selectedCount Task${if (selectedCount == 1) "" else "s"} & Continue"
|
||||
text = if (totalSelectedCount > 0) {
|
||||
"Add $totalSelectedCount Task${if (totalSelectedCount == 1) "" else "s"} & Continue"
|
||||
} else {
|
||||
stringResource(Res.string.onboarding_tasks_skip)
|
||||
},
|
||||
onClick = {
|
||||
if (selectedTaskIds.isEmpty()) {
|
||||
if (selectedBrowseIds.isEmpty() && selectedSuggestionIds.isEmpty()) {
|
||||
onTasksAdded()
|
||||
} else {
|
||||
val residences = DataManager.residences.value
|
||||
val residence = residences.firstOrNull()
|
||||
if (residence != null) {
|
||||
val today = DateUtils.getTodayString()
|
||||
val taskRequests = mutableListOf<TaskCreateRequest>()
|
||||
|
||||
val selectedTemplates = allTasks.filter { it.id in selectedTaskIds }
|
||||
val taskRequests = selectedTemplates.map { template ->
|
||||
// Browse tab selections
|
||||
val selectedBrowseTemplates = allBrowseTasks.filter { it.id in selectedBrowseIds }
|
||||
taskRequests.addAll(selectedBrowseTemplates.map { template ->
|
||||
val categoryId = DataManager.taskCategories.value
|
||||
.find { cat -> cat.name.lowercase() == template.category.lowercase() }
|
||||
?.id
|
||||
|
||||
val frequencyId = DataManager.taskFrequencies.value
|
||||
.find { freq -> freq.name.lowercase() == template.frequency.lowercase() }
|
||||
?.id
|
||||
|
||||
TaskCreateRequest(
|
||||
residenceId = residence.id,
|
||||
title = template.title,
|
||||
@@ -346,7 +376,29 @@ fun OnboardingFirstTaskContent(
|
||||
estimatedCost = null,
|
||||
contractorId = null
|
||||
)
|
||||
})
|
||||
|
||||
// For You tab selections
|
||||
val suggestions = (suggestionsState as? ApiResult.Success<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)
|
||||
} else {
|
||||
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
|
||||
private fun TaskCategorySection(
|
||||
category: OnboardingTaskCategory,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -126,7 +126,7 @@ fun OnboardingScreen(
|
||||
|
||||
OnboardingStep.JOIN_RESIDENCE -> OnboardingJoinResidenceContent(
|
||||
viewModel = viewModel,
|
||||
onJoined = { viewModel.nextStep() }
|
||||
onJoined = { viewModel.completeOnboarding() }
|
||||
)
|
||||
|
||||
OnboardingStep.RESIDENCE_LOCATION -> OnboardingLocationContent(
|
||||
@@ -138,15 +138,21 @@ fun OnboardingScreen(
|
||||
onSkip = { viewModel.nextStep() }
|
||||
)
|
||||
|
||||
OnboardingStep.FIRST_TASK -> OnboardingFirstTaskContent(
|
||||
OnboardingStep.HOME_PROFILE -> OnboardingHomeProfileContent(
|
||||
viewModel = viewModel,
|
||||
onTasksAdded = { viewModel.nextStep() }
|
||||
onContinue = { viewModel.nextStep() },
|
||||
onSkip = { viewModel.skipStep() }
|
||||
)
|
||||
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> OnboardingSubscriptionContent(
|
||||
onSubscribe = { viewModel.completeOnboarding() },
|
||||
onSkip = { viewModel.completeOnboarding() }
|
||||
OnboardingStep.FIRST_TASK -> OnboardingFirstTaskContent(
|
||||
viewModel = viewModel,
|
||||
onTasksAdded = { viewModel.completeOnboarding() }
|
||||
)
|
||||
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> {
|
||||
// Subscription removed from onboarding — app is free
|
||||
LaunchedEffect(Unit) { viewModel.completeOnboarding() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +170,7 @@ private fun OnboardingNavigationBar(
|
||||
OnboardingStep.WELCOME,
|
||||
OnboardingStep.JOIN_RESIDENCE,
|
||||
OnboardingStep.RESIDENCE_LOCATION,
|
||||
OnboardingStep.HOME_PROFILE,
|
||||
OnboardingStep.FIRST_TASK,
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> false
|
||||
else -> true
|
||||
@@ -173,6 +180,7 @@ private fun OnboardingNavigationBar(
|
||||
OnboardingStep.VALUE_PROPS,
|
||||
OnboardingStep.JOIN_RESIDENCE,
|
||||
OnboardingStep.RESIDENCE_LOCATION,
|
||||
OnboardingStep.HOME_PROFILE,
|
||||
OnboardingStep.FIRST_TASK,
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> true
|
||||
else -> false
|
||||
@@ -182,6 +190,7 @@ private fun OnboardingNavigationBar(
|
||||
OnboardingStep.WELCOME,
|
||||
OnboardingStep.JOIN_RESIDENCE,
|
||||
OnboardingStep.RESIDENCE_LOCATION,
|
||||
OnboardingStep.HOME_PROFILE,
|
||||
OnboardingStep.FIRST_TASK,
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> false
|
||||
else -> true
|
||||
@@ -195,6 +204,7 @@ private fun OnboardingNavigationBar(
|
||||
OnboardingStep.VERIFY_EMAIL -> 4
|
||||
OnboardingStep.JOIN_RESIDENCE -> 4
|
||||
OnboardingStep.RESIDENCE_LOCATION -> 4
|
||||
OnboardingStep.HOME_PROFILE -> 4
|
||||
OnboardingStep.FIRST_TASK -> 4
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> 4
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.tt.honeyDue.models.LoginRequest
|
||||
import com.tt.honeyDue.models.RegisterRequest
|
||||
import com.tt.honeyDue.models.ResidenceCreateRequest
|
||||
import com.tt.honeyDue.models.TaskCreateRequest
|
||||
import com.tt.honeyDue.models.TaskSuggestionsResponse
|
||||
import com.tt.honeyDue.models.TaskTemplate
|
||||
import com.tt.honeyDue.models.VerifyEmailRequest
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
@@ -37,6 +38,7 @@ enum class OnboardingStep {
|
||||
VERIFY_EMAIL,
|
||||
JOIN_RESIDENCE,
|
||||
RESIDENCE_LOCATION,
|
||||
HOME_PROFILE,
|
||||
FIRST_TASK,
|
||||
SUBSCRIPTION_UPSELL
|
||||
}
|
||||
@@ -90,6 +92,53 @@ class OnboardingViewModel : ViewModel() {
|
||||
private val _postalCode = MutableStateFlow("")
|
||||
val postalCode: StateFlow<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
|
||||
private val _isComplete = MutableStateFlow(false)
|
||||
val isComplete: StateFlow<Boolean> = _isComplete
|
||||
@@ -106,6 +155,32 @@ class OnboardingViewModel : ViewModel() {
|
||||
_shareCode.value = code
|
||||
}
|
||||
|
||||
// Home profile setters
|
||||
fun setHeatingType(value: String?) { _heatingType.value = value }
|
||||
fun setCoolingType(value: String?) { _coolingType.value = value }
|
||||
fun setWaterHeaterType(value: String?) { _waterHeaterType.value = value }
|
||||
fun setRoofType(value: String?) { _roofType.value = value }
|
||||
fun setHasPool(value: Boolean) { _hasPool.value = value }
|
||||
fun setHasSprinklerSystem(value: Boolean) { _hasSprinklerSystem.value = value }
|
||||
fun setHasSeptic(value: Boolean) { _hasSeptic.value = value }
|
||||
fun setHasFireplace(value: Boolean) { _hasFireplace.value = value }
|
||||
fun setHasGarage(value: Boolean) { _hasGarage.value = value }
|
||||
fun setHasBasement(value: Boolean) { _hasBasement.value = value }
|
||||
fun setHasAttic(value: Boolean) { _hasAttic.value = value }
|
||||
fun setExteriorType(value: String?) { _exteriorType.value = value }
|
||||
fun setFlooringPrimary(value: String?) { _flooringPrimary.value = value }
|
||||
fun setLandscapingType(value: String?) { _landscapingType.value = value }
|
||||
|
||||
/**
|
||||
* Load personalized task suggestions for the given residence.
|
||||
*/
|
||||
fun loadSuggestions(residenceId: Int) {
|
||||
viewModelScope.launch {
|
||||
_suggestionsState.value = ApiResult.Loading
|
||||
_suggestionsState.value = APILayer.getTaskSuggestions(residenceId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to the next step in the flow
|
||||
* Flow: Welcome → Features → Name Residence → Create Account → Verify → Tasks → Upsell
|
||||
@@ -129,9 +204,16 @@ class OnboardingViewModel : ViewModel() {
|
||||
OnboardingStep.RESIDENCE_LOCATION
|
||||
}
|
||||
}
|
||||
OnboardingStep.JOIN_RESIDENCE -> OnboardingStep.SUBSCRIPTION_UPSELL
|
||||
OnboardingStep.RESIDENCE_LOCATION -> OnboardingStep.FIRST_TASK
|
||||
OnboardingStep.FIRST_TASK -> OnboardingStep.SUBSCRIPTION_UPSELL
|
||||
OnboardingStep.JOIN_RESIDENCE -> {
|
||||
completeOnboarding()
|
||||
OnboardingStep.JOIN_RESIDENCE
|
||||
}
|
||||
OnboardingStep.RESIDENCE_LOCATION -> OnboardingStep.HOME_PROFILE
|
||||
OnboardingStep.HOME_PROFILE -> OnboardingStep.FIRST_TASK
|
||||
OnboardingStep.FIRST_TASK -> {
|
||||
completeOnboarding()
|
||||
OnboardingStep.FIRST_TASK
|
||||
}
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> {
|
||||
completeOnboarding()
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL
|
||||
@@ -171,9 +253,10 @@ class OnboardingViewModel : ViewModel() {
|
||||
fun skipStep() {
|
||||
when (_currentStep.value) {
|
||||
OnboardingStep.VALUE_PROPS,
|
||||
OnboardingStep.JOIN_RESIDENCE,
|
||||
OnboardingStep.RESIDENCE_LOCATION,
|
||||
OnboardingStep.FIRST_TASK -> nextStep()
|
||||
OnboardingStep.HOME_PROFILE -> nextStep()
|
||||
OnboardingStep.JOIN_RESIDENCE,
|
||||
OnboardingStep.FIRST_TASK,
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> completeOnboarding()
|
||||
else -> {}
|
||||
}
|
||||
@@ -272,7 +355,21 @@ class OnboardingViewModel : ViewModel() {
|
||||
description = null,
|
||||
purchaseDate = null,
|
||||
purchasePrice = null,
|
||||
isPrimary = true
|
||||
isPrimary = true,
|
||||
heatingType = _heatingType.value,
|
||||
coolingType = _coolingType.value,
|
||||
waterHeaterType = _waterHeaterType.value,
|
||||
roofType = _roofType.value,
|
||||
hasPool = _hasPool.value.takeIf { it },
|
||||
hasSprinklerSystem = _hasSprinklerSystem.value.takeIf { it },
|
||||
hasSeptic = _hasSeptic.value.takeIf { it },
|
||||
hasFireplace = _hasFireplace.value.takeIf { it },
|
||||
hasGarage = _hasGarage.value.takeIf { it },
|
||||
hasBasement = _hasBasement.value.takeIf { it },
|
||||
hasAttic = _hasAttic.value.takeIf { it },
|
||||
exteriorType = _exteriorType.value,
|
||||
flooringPrimary = _flooringPrimary.value,
|
||||
landscapingType = _landscapingType.value
|
||||
)
|
||||
)
|
||||
|
||||
@@ -362,6 +459,21 @@ class OnboardingViewModel : ViewModel() {
|
||||
_createTasksState.value = ApiResult.Idle
|
||||
_regionalTemplates.value = ApiResult.Idle
|
||||
_postalCode.value = ""
|
||||
_heatingType.value = null
|
||||
_coolingType.value = null
|
||||
_waterHeaterType.value = null
|
||||
_roofType.value = null
|
||||
_hasPool.value = false
|
||||
_hasSprinklerSystem.value = false
|
||||
_hasSeptic.value = false
|
||||
_hasFireplace.value = false
|
||||
_hasGarage.value = false
|
||||
_hasBasement.value = false
|
||||
_hasAttic.value = false
|
||||
_exteriorType.value = null
|
||||
_flooringPrimary.value = null
|
||||
_landscapingType.value = null
|
||||
_suggestionsState.value = ApiResult.Idle
|
||||
_isComplete.value = false
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,15 @@ struct OnboardingCoordinator: View {
|
||||
|
||||
isCreatingResidence = true
|
||||
|
||||
// Collect home profile booleans — only send true values
|
||||
let hasPool = onboardingState.pendingHasPool ? KotlinBoolean(bool: true) : nil
|
||||
let hasSprinkler = onboardingState.pendingHasSprinklerSystem ? KotlinBoolean(bool: true) : nil
|
||||
let hasSeptic = onboardingState.pendingHasSeptic ? KotlinBoolean(bool: true) : nil
|
||||
let hasFireplace = onboardingState.pendingHasFireplace ? KotlinBoolean(bool: true) : nil
|
||||
let hasGarage = onboardingState.pendingHasGarage ? KotlinBoolean(bool: true) : nil
|
||||
let hasBasement = onboardingState.pendingHasBasement ? KotlinBoolean(bool: true) : nil
|
||||
let hasAttic = onboardingState.pendingHasAttic ? KotlinBoolean(bool: true) : nil
|
||||
|
||||
let request = ResidenceCreateRequest(
|
||||
name: onboardingState.pendingResidenceName,
|
||||
propertyTypeId: nil,
|
||||
@@ -86,7 +95,21 @@ struct OnboardingCoordinator: View {
|
||||
description: nil,
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: KotlinBoolean(bool: true)
|
||||
isPrimary: KotlinBoolean(bool: true),
|
||||
heatingType: onboardingState.pendingHeatingType,
|
||||
coolingType: onboardingState.pendingCoolingType,
|
||||
waterHeaterType: onboardingState.pendingWaterHeaterType,
|
||||
roofType: onboardingState.pendingRoofType,
|
||||
hasPool: hasPool,
|
||||
hasSprinklerSystem: hasSprinkler,
|
||||
hasSeptic: hasSeptic,
|
||||
hasFireplace: hasFireplace,
|
||||
hasGarage: hasGarage,
|
||||
hasBasement: hasBasement,
|
||||
hasAttic: hasAttic,
|
||||
exteriorType: onboardingState.pendingExteriorType,
|
||||
flooringPrimary: onboardingState.pendingFlooringPrimary,
|
||||
landscapingType: onboardingState.pendingLandscapingType
|
||||
)
|
||||
|
||||
residenceViewModel.createResidence(request: request) { (residence: ResidenceResponse?) in
|
||||
@@ -103,7 +126,7 @@ struct OnboardingCoordinator: View {
|
||||
}
|
||||
|
||||
/// Current step index for progress indicator (0-based)
|
||||
/// Flow: Welcome → Features → Name → Account → Verify → Location → Tasks → Upsell
|
||||
/// Flow: Welcome → Features → Name → Account → Verify → Location → Home Profile → Tasks → Upsell
|
||||
private var currentProgressStep: Int {
|
||||
switch onboardingState.currentStep {
|
||||
case .welcome: return 0
|
||||
@@ -113,6 +136,7 @@ struct OnboardingCoordinator: View {
|
||||
case .verifyEmail: return 4
|
||||
case .joinResidence: return 4
|
||||
case .residenceLocation: return 4
|
||||
case .homeProfile: return 4
|
||||
case .firstTask: return 4
|
||||
case .subscriptionUpsell: return 4
|
||||
}
|
||||
@@ -121,7 +145,7 @@ struct OnboardingCoordinator: View {
|
||||
/// Whether to show the back button
|
||||
private var showBackButton: Bool {
|
||||
switch onboardingState.currentStep {
|
||||
case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
|
||||
case .welcome, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
@@ -131,7 +155,7 @@ struct OnboardingCoordinator: View {
|
||||
/// Whether to show the skip button
|
||||
private var showSkipButton: Bool {
|
||||
switch onboardingState.currentStep {
|
||||
case .valueProps, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
|
||||
case .valueProps, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -141,7 +165,7 @@ struct OnboardingCoordinator: View {
|
||||
/// Whether to show the progress indicator
|
||||
private var showProgressIndicator: Bool {
|
||||
switch onboardingState.currentStep {
|
||||
case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
|
||||
case .welcome, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
@@ -174,11 +198,15 @@ struct OnboardingCoordinator: View {
|
||||
case .valueProps:
|
||||
goForward()
|
||||
case .residenceLocation:
|
||||
// Skipping location — still need to create residence (without postal code)
|
||||
// Skipping location — go to home profile
|
||||
goForward(to: .homeProfile)
|
||||
case .homeProfile:
|
||||
// Skipping home profile — create residence without profile data, go to tasks
|
||||
createResidenceIfNeeded(thenNavigateTo: .firstTask)
|
||||
case .joinResidence, .firstTask:
|
||||
goForward()
|
||||
case .subscriptionUpsell:
|
||||
case .joinResidence:
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
case .firstTask, .subscriptionUpsell:
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
default:
|
||||
@@ -301,7 +329,8 @@ struct OnboardingCoordinator: View {
|
||||
case .joinResidence:
|
||||
OnboardingJoinResidenceContent(
|
||||
onJoined: {
|
||||
goForward()
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
@@ -309,9 +338,21 @@ struct OnboardingCoordinator: View {
|
||||
case .residenceLocation:
|
||||
OnboardingLocationContent(
|
||||
onLocationDetected: { zip in
|
||||
// Load regional templates in background while creating residence
|
||||
// Load regional templates in background
|
||||
onboardingState.loadRegionalTemplates(zip: zip)
|
||||
// Create residence with postal code, then go to first task
|
||||
// Go to home profile step (residence created after profile)
|
||||
goForward()
|
||||
},
|
||||
onSkip: {
|
||||
// Handled by handleSkip() above
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .homeProfile:
|
||||
OnboardingHomeProfileContent(
|
||||
onContinue: {
|
||||
// Create residence with all collected data, then go to tasks
|
||||
createResidenceIfNeeded(thenNavigateTo: .firstTask)
|
||||
},
|
||||
onSkip: {
|
||||
@@ -324,20 +365,21 @@ struct OnboardingCoordinator: View {
|
||||
OnboardingFirstTaskContent(
|
||||
residenceName: onboardingState.pendingResidenceName,
|
||||
onTaskAdded: {
|
||||
goForward()
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .subscriptionUpsell:
|
||||
OnboardingSubscriptionContent(
|
||||
onSubscribe: {
|
||||
// Handle subscription flow
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .subscriptionUpsell:
|
||||
// Subscription removed from onboarding — app is free
|
||||
// Immediately complete if we somehow land here
|
||||
EmptyView()
|
||||
.onAppear {
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
}
|
||||
.transition(navigationTransition)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: onboardingState.currentStep)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
/// Tab selection for task browsing
|
||||
enum OnboardingTaskTab: String, CaseIterable {
|
||||
case forYou = "For You"
|
||||
case browse = "Browse All"
|
||||
}
|
||||
|
||||
/// Screen 6: First task prompt with suggested templates - Content only (no navigation bar)
|
||||
struct OnboardingFirstTaskContent: View {
|
||||
var residenceName: String
|
||||
@@ -13,10 +19,12 @@ struct OnboardingFirstTaskContent: View {
|
||||
@State private var isCreatingTasks = false
|
||||
@State private var expandedCategories: Set<String> = []
|
||||
@State private var isAnimating = false
|
||||
@State private var selectedTab: OnboardingTaskTab = .forYou
|
||||
@State private var forYouTemplates: [OnboardingTaskTemplate] = []
|
||||
@State private var isLoadingSuggestions = false
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
/// Maximum tasks allowed for free tier (matches API TierLimits)
|
||||
private let maxTasksAllowed = 5
|
||||
// No task selection limit — users can add as many as they want
|
||||
|
||||
/// Category colors by name (used for both API and fallback templates)
|
||||
private static let categoryColors: [String: Color] = [
|
||||
@@ -173,7 +181,7 @@ struct OnboardingFirstTaskContent: View {
|
||||
}
|
||||
|
||||
private var isAtMaxSelection: Bool {
|
||||
selectedTasks.count >= maxTasksAllowed
|
||||
false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -304,88 +312,107 @@ struct OnboardingFirstTaskContent: View {
|
||||
Image(systemName: isAtMaxSelection ? "checkmark.seal.fill" : "checkmark.circle.fill")
|
||||
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
|
||||
|
||||
Text("\(selectedCount)/\(maxTasksAllowed) tasks selected")
|
||||
Text("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 10)
|
||||
.background((isAtMaxSelection ? Color.appAccent : Color.appPrimary).opacity(0.1))
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
.animation(.spring(response: 0.3), value: selectedCount)
|
||||
.accessibilityLabel("\(selectedCount) of \(maxTasksAllowed) tasks selected")
|
||||
.accessibilityLabel("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected")
|
||||
|
||||
// Task categories
|
||||
VStack(spacing: 12) {
|
||||
ForEach(taskCategories) { category in
|
||||
OrganicTaskCategorySection(
|
||||
category: category,
|
||||
selectedTasks: $selectedTasks,
|
||||
isExpanded: expandedCategories.contains(category.name),
|
||||
isAtMaxSelection: isAtMaxSelection,
|
||||
onToggleExpand: {
|
||||
let isExpanding = !expandedCategories.contains(category.name)
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
if expandedCategories.contains(category.name) {
|
||||
expandedCategories.remove(category.name)
|
||||
} else {
|
||||
expandedCategories.insert(category.name)
|
||||
// Tab bar
|
||||
OnboardingTaskTabBar(selectedTab: $selectedTab)
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
|
||||
// Tab content
|
||||
switch selectedTab {
|
||||
case .forYou:
|
||||
// For You tab — personalized suggestions
|
||||
ForYouTasksTab(
|
||||
forYouTemplates: forYouTemplates,
|
||||
isLoading: isLoadingSuggestions,
|
||||
selectedTasks: $selectedTasks,
|
||||
isAtMaxSelection: isAtMaxSelection,
|
||||
hasResidence: onboardingState.createdResidenceId != nil
|
||||
)
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
|
||||
case .browse:
|
||||
// Browse tab — existing category browser
|
||||
VStack(spacing: 12) {
|
||||
ForEach(taskCategories) { category in
|
||||
OrganicTaskCategorySection(
|
||||
category: category,
|
||||
selectedTasks: $selectedTasks,
|
||||
isExpanded: expandedCategories.contains(category.name),
|
||||
isAtMaxSelection: isAtMaxSelection,
|
||||
onToggleExpand: {
|
||||
let isExpanding = !expandedCategories.contains(category.name)
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
if expandedCategories.contains(category.name) {
|
||||
expandedCategories.remove(category.name)
|
||||
} else {
|
||||
expandedCategories.insert(category.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if isExpanding {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
withAnimation {
|
||||
proxy.scrollTo(category.name, anchor: .top)
|
||||
if isExpanding {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
withAnimation {
|
||||
proxy.scrollTo(category.name, anchor: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.id(category.name)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
|
||||
// Quick add all popular
|
||||
Button(action: selectPopularTasks) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
|
||||
Text("Add Most Popular")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appAccent],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.1), Color.appAccent.opacity(0.1)],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
),
|
||||
lineWidth: 1.5
|
||||
)
|
||||
)
|
||||
.id(category.name)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
|
||||
// Quick add all popular
|
||||
Button(action: selectPopularTasks) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
|
||||
Text("Add Most Popular")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appAccent],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.1), Color.appAccent.opacity(0.1)],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
),
|
||||
lineWidth: 1.5
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
.a11yButton("Add popular tasks")
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
.a11yButton("Add popular tasks")
|
||||
}
|
||||
.padding(.bottom, 140) // Space for button
|
||||
}
|
||||
@@ -448,6 +475,8 @@ struct OnboardingFirstTaskContent: View {
|
||||
if let first = taskCategories.first?.name {
|
||||
expandedCategories.insert(first)
|
||||
}
|
||||
// Build "For You" suggestions based on home profile
|
||||
buildForYouSuggestions()
|
||||
}
|
||||
.onDisappear {
|
||||
isAnimating = false
|
||||
@@ -457,11 +486,9 @@ struct OnboardingFirstTaskContent: View {
|
||||
private func selectPopularTasks() {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
if !onboardingState.regionalTemplates.isEmpty {
|
||||
// API templates: select the first N tasks (they're ordered by display_order)
|
||||
// API templates: select the first tasks (they're ordered by display_order)
|
||||
for task in allTasks {
|
||||
if selectedTasks.count < maxTasksAllowed {
|
||||
selectedTasks.insert(task.id)
|
||||
}
|
||||
selectedTasks.insert(task.id)
|
||||
}
|
||||
} else {
|
||||
// Fallback: select hardcoded popular tasks
|
||||
@@ -473,14 +500,164 @@ struct OnboardingFirstTaskContent: View {
|
||||
"Clean Refrigerator Coils"
|
||||
]
|
||||
for task in allTasks where popularTaskTitles.contains(task.title) {
|
||||
if selectedTasks.count < maxTasksAllowed {
|
||||
selectedTasks.insert(task.id)
|
||||
}
|
||||
selectedTasks.insert(task.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build personalized "For You" suggestions based on the home profile selections
|
||||
private func buildForYouSuggestions() {
|
||||
var suggestions: [ForYouSuggestion] = []
|
||||
|
||||
let state = onboardingState
|
||||
|
||||
// HVAC-related suggestions based on heating/cooling type
|
||||
if state.pendingHeatingType != nil || state.pendingCoolingType != nil {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "fanblades.fill", title: "Change HVAC Filter", category: "hvac", frequency: "monthly", color: .appPrimary),
|
||||
relevance: .great, reason: "Based on your HVAC system"
|
||||
))
|
||||
}
|
||||
if state.pendingHeatingType == "gas_furnace" || state.pendingHeatingType == "boiler" {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "flame.fill", title: "Inspect Furnace", category: "hvac", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange),
|
||||
relevance: .great, reason: "You have a gas system"
|
||||
))
|
||||
}
|
||||
if state.pendingCoolingType == "central_ac" || state.pendingCoolingType == "heat_pump" {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "air.conditioner.horizontal.fill", title: "Schedule AC Tune-Up", category: "hvac", frequency: "yearly", color: .appPrimary),
|
||||
relevance: .great, reason: "Central cooling needs annual service"
|
||||
))
|
||||
}
|
||||
|
||||
// Water heater
|
||||
if state.pendingWaterHeaterType != nil {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "bolt.horizontal.fill", title: "Flush Water Heater", category: "plumbing", frequency: "yearly", color: Color(hex: "#FF9500") ?? .orange),
|
||||
relevance: state.pendingWaterHeaterType?.contains("tank") == true ? .great : .good,
|
||||
reason: "Extends water heater life"
|
||||
))
|
||||
}
|
||||
|
||||
// Pool
|
||||
if state.pendingHasPool {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "figure.pool.swim", title: "Check Pool Chemistry", category: "exterior", frequency: "weekly", color: .appSecondary),
|
||||
relevance: .great, reason: "You have a pool"
|
||||
))
|
||||
}
|
||||
|
||||
// Sprinklers
|
||||
if state.pendingHasSprinklerSystem {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "sprinkler.and.droplets.fill", title: "Check Sprinkler System", category: "landscaping", frequency: "monthly", color: Color(hex: "#34C759") ?? .green),
|
||||
relevance: .great, reason: "You have sprinklers"
|
||||
))
|
||||
}
|
||||
|
||||
// Fireplace
|
||||
if state.pendingHasFireplace {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "fireplace.fill", title: "Inspect Chimney & Fireplace", category: "interior", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange),
|
||||
relevance: .great, reason: "You have a fireplace"
|
||||
))
|
||||
}
|
||||
|
||||
// Garage
|
||||
if state.pendingHasGarage {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "lock.fill", title: "Test Garage Door Safety", category: "safety", frequency: "monthly", color: .appSecondary),
|
||||
relevance: .good, reason: "You have a garage"
|
||||
))
|
||||
}
|
||||
|
||||
// Basement
|
||||
if state.pendingHasBasement {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "drop.fill", title: "Check Basement for Moisture", category: "interior", frequency: "monthly", color: .appSecondary),
|
||||
relevance: .good, reason: "You have a basement"
|
||||
))
|
||||
}
|
||||
|
||||
// Septic
|
||||
if state.pendingHasSeptic {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "drop.triangle.fill", title: "Schedule Septic Inspection", category: "plumbing", frequency: "yearly", color: .appPrimary),
|
||||
relevance: .great, reason: "You have a septic system"
|
||||
))
|
||||
}
|
||||
|
||||
// Attic
|
||||
if state.pendingHasAttic {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "arrow.up.square.fill", title: "Inspect Attic Insulation", category: "interior", frequency: "yearly", color: Color(hex: "#AF52DE") ?? .purple),
|
||||
relevance: .good, reason: "You have an attic"
|
||||
))
|
||||
}
|
||||
|
||||
// Roof-based
|
||||
if state.pendingRoofType != nil {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "cloud.rain.fill", title: "Clean Gutters", category: "exterior", frequency: "semiannually", color: .appSecondary),
|
||||
relevance: .great, reason: "Protects your roof"
|
||||
))
|
||||
}
|
||||
|
||||
// Always-recommended essentials (lower priority)
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "smoke.fill", title: "Test Smoke Detectors", category: "safety", frequency: "monthly", color: .appError),
|
||||
relevance: .good, reason: "Essential safety task"
|
||||
))
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "drop.fill", title: "Check for Leaks", category: "plumbing", frequency: "monthly", color: .appSecondary),
|
||||
relevance: .good, reason: "Prevents water damage"
|
||||
))
|
||||
|
||||
// Landscaping
|
||||
if state.pendingLandscapingType == "lawn" || state.pendingLandscapingType == "garden" || state.pendingLandscapingType == "mixed" {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "leaf.fill", title: "Lawn Care", category: "landscaping", frequency: "weekly", color: Color(hex: "#34C759") ?? .green),
|
||||
relevance: .good, reason: "Based on your landscaping"
|
||||
))
|
||||
}
|
||||
|
||||
// Sort: great first, then good; deduplicate by title
|
||||
var seen = Set<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() {
|
||||
// If no tasks selected, just skip
|
||||
if selectedTasks.isEmpty {
|
||||
@@ -497,9 +674,14 @@ struct OnboardingFirstTaskContent: View {
|
||||
|
||||
isCreatingTasks = true
|
||||
|
||||
let selectedTemplates = allTasks.filter { selectedTasks.contains($0.id) }
|
||||
// Collect from both browse and For You templates
|
||||
let allAvailable = allTasks + forYouTemplates
|
||||
let selectedTemplates = allAvailable.filter { selectedTasks.contains($0.id) }
|
||||
// Deduplicate by title (same task might exist in both tabs)
|
||||
var seenTitles = Set<String>()
|
||||
let uniqueTemplates = selectedTemplates.filter { seenTitles.insert($0.title).inserted }
|
||||
var completedCount = 0
|
||||
let totalCount = selectedTemplates.count
|
||||
let totalCount = uniqueTemplates.count
|
||||
|
||||
// Safety: if no templates matched (shouldn't happen), skip
|
||||
if totalCount == 0 {
|
||||
@@ -516,7 +698,7 @@ struct OnboardingFirstTaskContent: View {
|
||||
|
||||
print("🏠 ONBOARDING: Creating \(totalCount) tasks for residence \(residenceId)")
|
||||
|
||||
for template in selectedTemplates {
|
||||
for template in uniqueTemplates {
|
||||
// Look up category ID from DataManager
|
||||
let categoryId: Int32? = {
|
||||
return dataManager.taskCategories.first { $0.name.caseInsensitiveCompare(template.category) == .orderedSame }?.id
|
||||
@@ -760,6 +942,230 @@ struct OnboardingTaskTemplate: Identifiable {
|
||||
let color: Color
|
||||
}
|
||||
|
||||
// MARK: - For You Suggestion Model
|
||||
|
||||
enum SuggestionRelevance: Int {
|
||||
case good = 1
|
||||
case great = 2
|
||||
}
|
||||
|
||||
struct ForYouSuggestion {
|
||||
let template: OnboardingTaskTemplate
|
||||
let relevance: SuggestionRelevance
|
||||
let reason: String
|
||||
}
|
||||
|
||||
// MARK: - Tab Bar
|
||||
|
||||
private struct OnboardingTaskTabBar: View {
|
||||
@Binding var selectedTab: OnboardingTaskTab
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(OnboardingTaskTab.allCases, id: \.self) { tab in
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
selectedTab = tab
|
||||
}
|
||||
} label: {
|
||||
VStack(spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
if tab == .forYou {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
} else {
|
||||
Image(systemName: "square.grid.2x2.fill")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
}
|
||||
Text(tab.rawValue)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(selectedTab == tab ? Color.appPrimary : Color.appTextSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// Indicator
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(selectedTab == tab ? Color.appPrimary : Color.clear)
|
||||
.frame(height: 3)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
ZStack {
|
||||
Color.appBackgroundSecondary
|
||||
GrainTexture(opacity: 0.01)
|
||||
}
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - For You Tasks Tab
|
||||
|
||||
private struct ForYouTasksTab: View {
|
||||
let forYouTemplates: [OnboardingTaskTemplate]
|
||||
let isLoading: Bool
|
||||
@Binding var selectedTasks: Set<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)
|
||||
|
||||
struct OnboardingFirstTaskView: View {
|
||||
|
||||
517
iosApp/iosApp/Onboarding/OnboardingHomeProfileView.swift
Normal file
517
iosApp/iosApp/Onboarding/OnboardingHomeProfileView.swift
Normal 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: {}
|
||||
)
|
||||
}
|
||||
@@ -45,6 +45,23 @@ class OnboardingState: ObservableObject {
|
||||
/// Whether regional templates are currently loading
|
||||
@Published var isLoadingTemplates: Bool = false
|
||||
|
||||
// MARK: - Home Profile State (collected during onboarding)
|
||||
|
||||
@Published var pendingHeatingType: String? = nil
|
||||
@Published var pendingCoolingType: String? = nil
|
||||
@Published var pendingWaterHeaterType: String? = nil
|
||||
@Published var pendingRoofType: String? = nil
|
||||
@Published var pendingHasPool: Bool = false
|
||||
@Published var pendingHasSprinklerSystem: Bool = false
|
||||
@Published var pendingHasSeptic: Bool = false
|
||||
@Published var pendingHasFireplace: Bool = false
|
||||
@Published var pendingHasGarage: Bool = false
|
||||
@Published var pendingHasBasement: Bool = false
|
||||
@Published var pendingHasAttic: Bool = false
|
||||
@Published var pendingExteriorType: String? = nil
|
||||
@Published var pendingFlooringPrimary: String? = nil
|
||||
@Published var pendingLandscapingType: String? = nil
|
||||
|
||||
/// The user's selected intent (start fresh or join existing).
|
||||
/// Reads/writes the persisted @AppStorage value and notifies SwiftUI of the change.
|
||||
var userIntent: OnboardingIntent {
|
||||
@@ -107,11 +124,13 @@ class OnboardingState: ObservableObject {
|
||||
currentStep = .residenceLocation
|
||||
}
|
||||
case .joinResidence:
|
||||
currentStep = .subscriptionUpsell
|
||||
completeOnboarding()
|
||||
case .residenceLocation:
|
||||
currentStep = .homeProfile
|
||||
case .homeProfile:
|
||||
currentStep = .firstTask
|
||||
case .firstTask:
|
||||
currentStep = .subscriptionUpsell
|
||||
completeOnboarding()
|
||||
case .subscriptionUpsell:
|
||||
completeOnboarding()
|
||||
}
|
||||
@@ -137,6 +156,7 @@ class OnboardingState: ObservableObject {
|
||||
regionalTemplates = []
|
||||
createdResidenceId = nil
|
||||
userIntent = .unknown
|
||||
resetHomeProfile()
|
||||
}
|
||||
|
||||
/// Reset onboarding state (useful for testing or re-onboarding).
|
||||
@@ -150,6 +170,25 @@ class OnboardingState: ObservableObject {
|
||||
createdResidenceId = nil
|
||||
userIntent = .unknown
|
||||
currentStep = .welcome
|
||||
resetHomeProfile()
|
||||
}
|
||||
|
||||
/// Reset all home profile fields
|
||||
private func resetHomeProfile() {
|
||||
pendingHeatingType = nil
|
||||
pendingCoolingType = nil
|
||||
pendingWaterHeaterType = nil
|
||||
pendingRoofType = nil
|
||||
pendingHasPool = false
|
||||
pendingHasSprinklerSystem = false
|
||||
pendingHasSeptic = false
|
||||
pendingHasFireplace = false
|
||||
pendingHasGarage = false
|
||||
pendingHasBasement = false
|
||||
pendingHasAttic = false
|
||||
pendingExteriorType = nil
|
||||
pendingFlooringPrimary = nil
|
||||
pendingLandscapingType = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,8 +201,9 @@ enum OnboardingStep: Int, CaseIterable {
|
||||
case verifyEmail = 4
|
||||
case joinResidence = 5 // Only for users joining with a code
|
||||
case residenceLocation = 6 // ZIP code entry for regional templates
|
||||
case firstTask = 7
|
||||
case subscriptionUpsell = 8
|
||||
case homeProfile = 7 // Home systems & features (optional)
|
||||
case firstTask = 8
|
||||
case subscriptionUpsell = 9
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
@@ -181,6 +221,8 @@ enum OnboardingStep: Int, CaseIterable {
|
||||
return "Join Residence"
|
||||
case .residenceLocation:
|
||||
return "Your Location"
|
||||
case .homeProfile:
|
||||
return "Home Profile"
|
||||
case .firstTask:
|
||||
return "First Task"
|
||||
case .subscriptionUpsell:
|
||||
|
||||
@@ -362,6 +362,20 @@ class ResidenceViewModel: ObservableObject {
|
||||
isActive: true,
|
||||
overdueCount: 0,
|
||||
completionSummary: nil,
|
||||
heatingType: nil,
|
||||
coolingType: nil,
|
||||
waterHeaterType: nil,
|
||||
roofType: nil,
|
||||
hasPool: false,
|
||||
hasSprinklerSystem: false,
|
||||
hasSeptic: false,
|
||||
hasFireplace: false,
|
||||
hasGarage: false,
|
||||
hasBasement: false,
|
||||
hasAttic: false,
|
||||
exteriorType: nil,
|
||||
flooringPrimary: nil,
|
||||
landscapingType: nil,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
)
|
||||
|
||||
@@ -395,7 +395,21 @@ struct ResidenceFormView: View {
|
||||
description: description.isEmpty ? nil : description,
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: KotlinBoolean(bool: isPrimary)
|
||||
isPrimary: KotlinBoolean(bool: isPrimary),
|
||||
heatingType: nil,
|
||||
coolingType: nil,
|
||||
waterHeaterType: nil,
|
||||
roofType: nil,
|
||||
hasPool: nil,
|
||||
hasSprinklerSystem: nil,
|
||||
hasSeptic: nil,
|
||||
hasFireplace: nil,
|
||||
hasGarage: nil,
|
||||
hasBasement: nil,
|
||||
hasAttic: nil,
|
||||
exteriorType: nil,
|
||||
flooringPrimary: nil,
|
||||
landscapingType: nil
|
||||
)
|
||||
|
||||
if let residence = existingResidence {
|
||||
|
||||
@@ -318,6 +318,20 @@ private struct PropertyHeaderBackground: View {
|
||||
isActive: true,
|
||||
overdueCount: 0,
|
||||
completionSummary: nil,
|
||||
heatingType: nil,
|
||||
coolingType: nil,
|
||||
waterHeaterType: nil,
|
||||
roofType: nil,
|
||||
hasPool: false,
|
||||
hasSprinklerSystem: false,
|
||||
hasSeptic: false,
|
||||
hasFireplace: false,
|
||||
hasGarage: false,
|
||||
hasBasement: false,
|
||||
hasAttic: false,
|
||||
exteriorType: nil,
|
||||
flooringPrimary: nil,
|
||||
landscapingType: nil,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z"
|
||||
))
|
||||
|
||||
@@ -308,6 +308,20 @@ private struct CardBackgroundView: View {
|
||||
isActive: true,
|
||||
overdueCount: 2,
|
||||
completionSummary: nil,
|
||||
heatingType: nil,
|
||||
coolingType: nil,
|
||||
waterHeaterType: nil,
|
||||
roofType: nil,
|
||||
hasPool: false,
|
||||
hasSprinklerSystem: false,
|
||||
hasSeptic: false,
|
||||
hasFireplace: false,
|
||||
hasGarage: false,
|
||||
hasBasement: false,
|
||||
hasAttic: false,
|
||||
exteriorType: nil,
|
||||
flooringPrimary: nil,
|
||||
landscapingType: nil,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z"
|
||||
),
|
||||
@@ -341,6 +355,20 @@ private struct CardBackgroundView: View {
|
||||
isActive: true,
|
||||
overdueCount: 0,
|
||||
completionSummary: nil,
|
||||
heatingType: nil,
|
||||
coolingType: nil,
|
||||
waterHeaterType: nil,
|
||||
roofType: nil,
|
||||
hasPool: false,
|
||||
hasSprinklerSystem: false,
|
||||
hasSeptic: false,
|
||||
hasFireplace: false,
|
||||
hasGarage: false,
|
||||
hasBasement: false,
|
||||
hasAttic: false,
|
||||
exteriorType: nil,
|
||||
flooringPrimary: nil,
|
||||
landscapingType: nil,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z"
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user