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