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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user