Wire onboarding task suggestions to backend, delete hardcoded catalog

Both "For You" and "Browse All" tabs are now fully server-driven on
iOS and Android. No on-device task list, no client-side scoring rules.
When the API fails the screen shows error + Retry + Skip so onboarding
can still complete on a flaky network.

Shared (KMM)
- TaskCreateRequest + TaskResponse carry templateId
- New BulkCreateTasksRequest/Response, TaskApi.bulkCreateTasks,
  APILayer.bulkCreateTasks (updates DataManager + TotalSummary)
- OnboardingViewModel: templatesGroupedState + loadTemplatesGrouped;
  createTasks(residenceId, requests) posts once via the bulk path
- Deleted regional-template plumbing: APILayer.getRegionalTemplates,
  OnboardingViewModel.loadRegionalTemplates, TaskTemplateApi.
  getTemplatesByRegion, TaskTemplate.regionId/regionName
- 5 new AnalyticsEvents constants for the onboarding funnel

Android (Compose)
- OnboardingFirstTaskContent rewritten against the server catalog;
  ~70 lines of hardcoded taskCategories gone. Loading / Error / Empty
  panes with Retry + Skip buttons. Category icons derived from name
  keywords, colours from a 5-value palette keyed by category id
- Browse selection carries template.id into the bulk request so
  task_template_id is populated server-side

iOS (SwiftUI)
- New OnboardingTasksViewModel (@MainActor ObservableObject) wrapping
  APILayer.shared for suggestions / grouped / bulk-submit with
  loading + error state (mirrors the TaskViewModel.swift pattern)
- OnboardingFirstTaskView rewritten: buildForYouSuggestions (130 lines)
  and fallbackCategories (68 lines) deleted; both tabs show the same
  error+skip UX as Android; ForYouSuggestion/SuggestionRelevance gone
- 5 new AnalyticsEvent cases with identical PostHog event names to
  the Kotlin constants so cross-platform funnels join cleanly
- Existing TaskCreateRequest / TaskResponse call sites in TaskCard,
  TasksSection, TaskFormView updated for the new templateId parameter

Docs
- CLAUDE.md gains an "Onboarding task suggestions (server-driven)"
  subsection covering the data flow, key files on both platforms,
  and the KotlinInt(int: template.id) wrapping requirement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-14 15:25:01 -05:00
parent d545fd463c
commit 9ececfa48a
16 changed files with 1399 additions and 1255 deletions

View File

@@ -1219,6 +1219,48 @@ val CURRENT_ENV = Environment.DEV // or Environment.LOCAL
3. Add method to relevant ViewModel that calls APILayer
4. Update UI to observe the new StateFlow
### Onboarding task suggestions (server-driven)
The First-Task onboarding screen is **fully server-driven** on both
platforms. There is no hardcoded catalog or client-side suggestion rules;
when the API fails the screen shows error + Retry + Skip.
**Data flow:**
```
"For You" tab → APILayer.getTaskSuggestions(residenceId)
→ GET /api/tasks/suggestions/?residence_id=X
→ scored against 15 home-profile fields (incl. climate zone)
"Browse All" tab → APILayer.getTaskTemplatesGrouped()
→ GET /api/tasks/templates/grouped/
→ cached on DataManager.taskTemplatesGrouped (24h TTL)
Submit → APILayer.bulkCreateTasks(BulkCreateTasksRequest)
→ POST /api/tasks/bulk/
→ single DB transaction, all-or-nothing
```
**Key files:**
- Shared ViewModel: `composeApp/.../viewmodel/OnboardingViewModel.kt`
(`suggestionsState`, `templatesGroupedState`, `createTasks`)
- Android screen: `composeApp/.../ui/screens/onboarding/OnboardingFirstTaskContent.kt`
- iOS Swift wrapper: `iosApp/iosApp/Onboarding/OnboardingTasksViewModel.swift`
(mirrors the Kotlin ViewModel but calls `APILayer.shared` directly in
Swift rather than observing Kotlin StateFlows — matches the convention in
`iosApp/iosApp/Task/TaskViewModel.swift`)
- iOS view: `iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift`
- Analytics: 5 shared event names in `AnalyticsEvents` (Kotlin) +
`AnalyticsEvent` (Swift) — `onboarding_suggestions_loaded`,
`onboarding_suggestion_accepted`, `onboarding_browse_template_accepted`,
`onboarding_tasks_created`, `onboarding_task_step_skipped`.
**When selecting a template from either tab**, always populate
`TaskCreateRequest.templateId` with the backend `TaskTemplate.id` so the
created task carries the template backlink for reporting. Swift wraps the
id as `KotlinInt(int: template.id)`.
### Handling Platform-Specific Code
Use `expect/actual` pattern:

View File

@@ -36,6 +36,20 @@ object AnalyticsEvents {
const val NEW_TASK_SCREEN_SHOWN = "new_task_screen_shown"
const val TASK_CREATED = "task_created"
// Onboarding — First Task screen funnel
// Fired by both iOS and Android with identical event names so the
// PostHog funnel is cross-platform. Properties documented next to each.
// ONBOARDING_SUGGESTIONS_LOADED: {"count": Int, "profile_completeness": Double}
// ONBOARDING_SUGGESTION_ACCEPTED: {"template_id": Int, "relevance_score": Double}
// ONBOARDING_BROWSE_TEMPLATE_ACCEPTED: {"template_id": Int, "category": String?}
// ONBOARDING_TASKS_CREATED: {"count": Int}
// ONBOARDING_TASK_STEP_SKIPPED: {"reason": "network_error" | "user_skip"}
const val ONBOARDING_SUGGESTIONS_LOADED = "onboarding_suggestions_loaded"
const val ONBOARDING_SUGGESTION_ACCEPTED = "onboarding_suggestion_accepted"
const val ONBOARDING_BROWSE_TEMPLATE_ACCEPTED = "onboarding_browse_template_accepted"
const val ONBOARDING_TASKS_CREATED = "onboarding_tasks_created"
const val ONBOARDING_TASK_STEP_SKIPPED = "onboarding_task_step_skipped"
// Contractor
const val CONTRACTOR_SCREEN_SHOWN = "contractor_screen_shown"
const val NEW_CONTRACTOR_SCREEN_SHOWN = "new_contractor_screen_shown"

View File

@@ -53,6 +53,9 @@ data class TaskResponse(
@SerialName("is_cancelled") val isCancelled: Boolean = false,
@SerialName("is_archived") val isArchived: Boolean = false,
@SerialName("parent_task_id") val parentTaskId: Int? = null,
// Backlink to the TaskTemplate this task was spawned from (onboarding
// suggestion or browse catalog). Null for user-created custom tasks.
@SerialName("template_id") val templateId: Int? = null,
@SerialName("completion_count") val completionCount: Int = 0,
@SerialName("kanban_column") val kanbanColumn: String? = null, // Which kanban column this task belongs to
// Note: Go API does not return completions inline with TaskResponse.
@@ -133,7 +136,33 @@ data class TaskCreateRequest(
@SerialName("assigned_to_id") val assignedToId: Int? = null,
@SerialName("due_date") val dueDate: String? = null,
@SerialName("estimated_cost") val estimatedCost: Double? = null,
@SerialName("contractor_id") val contractorId: Int? = null
@SerialName("contractor_id") val contractorId: Int? = null,
// Set when the task is spawned from a TaskTemplate (onboarding
// suggestion or browse catalog). Null for free-form custom tasks.
@SerialName("template_id") val templateId: Int? = null
)
/**
* Bulk create request matching Go API BulkCreateTasksRequest.
* Used by onboarding to insert 1-50 tasks atomically in a single
* transaction. The server forces every entry's residence_id to the
* top-level value, so any mismatch in the list is silently corrected.
*/
@Serializable
data class BulkCreateTasksRequest(
@SerialName("residence_id") val residenceId: Int,
val tasks: List<TaskCreateRequest>
)
/**
* Bulk create response matching Go API BulkCreateTasksResponse.
* All [tasks] are created-or-none — partial state never reaches the client.
*/
@Serializable
data class BulkCreateTasksResponse(
val tasks: List<TaskResponse>,
val summary: TotalSummary,
@SerialName("created_count") val createdCount: Int
)
/**

View File

@@ -20,9 +20,7 @@ data class TaskTemplate(
@SerialName("icon_android") val iconAndroid: String = "",
val tags: List<String> = emptyList(),
@SerialName("display_order") val displayOrder: Int = 0,
@SerialName("is_active") val isActive: Boolean = true,
@SerialName("region_id") val regionId: Int? = null,
@SerialName("region_name") val regionName: String? = null
@SerialName("is_active") val isActive: Boolean = true
) {
/**
* Human-readable frequency display

View File

@@ -638,6 +638,23 @@ object APILayer {
}
}
/**
* Atomically creates 1-50 tasks via POST /api/tasks/bulk/. The whole
* batch succeeds or fails together on the server. On success, every
* returned task is merged into DataManager.allTasks so observing views
* render the new batch immediately.
*/
suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
val result = taskApi.bulkCreateTasks(token, request)
if (result is ApiResult.Success) {
DataManager.setTotalSummary(result.data.summary)
result.data.tasks.forEach { DataManager.updateTask(it) }
}
return result
}
suspend fun updateTask(id: Int, request: TaskCreateRequest): ApiResult<TaskResponse> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
val result = taskApi.updateTask(token, id, request)
@@ -1200,15 +1217,6 @@ object APILayer {
} ?: ApiResult.Error("Task template not found")
}
/**
* Get task templates filtered by climate region.
* Accepts either a state abbreviation or ZIP code — backend resolves to climate zone.
* This calls the API directly since regional templates are not cached in seeded data.
*/
suspend fun getRegionalTemplates(state: String? = null, zip: String? = null): ApiResult<List<TaskTemplate>> {
return taskTemplateApi.getTemplatesByRegion(state = state, zip = zip)
}
/**
* Get personalized task suggestions for a residence based on its home profile.
*/

View File

@@ -66,6 +66,31 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
}
}
/**
* Atomically creates 1-50 tasks in a single transaction. Used by
* onboarding and anywhere else that needs "all or nothing" task
* creation. The server overrides every entry's residence_id with the
* top-level request.residenceId.
*/
suspend fun bulkCreateTasks(token: String, request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
return try {
val response = client.post("$baseUrl/tasks/bulk/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
return try {
val response = client.put("$baseUrl/tasks/$id/") {

View File

@@ -85,27 +85,6 @@ class TaskTemplateApi(private val client: HttpClient = ApiClient.httpClient) {
}
}
/**
* Get templates filtered by climate region.
* Accepts either a state abbreviation or ZIP code — backend resolves to climate zone.
*/
suspend fun getTemplatesByRegion(state: String? = null, zip: String? = null): ApiResult<List<TaskTemplate>> {
return try {
val response = client.get("$baseUrl/tasks/templates/by-region/") {
state?.let { parameter("state", it) }
zip?.let { parameter("zip", it) }
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch regional templates", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Get personalized task suggestions for a residence based on its home profile.
* Requires authentication.

View File

@@ -15,36 +15,44 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tt.honeyDue.analytics.AnalyticsEvents
import com.tt.honeyDue.analytics.PostHogAnalytics
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.models.TaskCreateRequest
import com.tt.honeyDue.models.TaskSuggestionResponse
import com.tt.honeyDue.models.TaskSuggestionsResponse
import com.tt.honeyDue.models.TaskTemplate
import com.tt.honeyDue.models.TaskTemplateCategoryGroup
import com.tt.honeyDue.models.TaskTemplatesGroupedResponse
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.ui.theme.*
import com.tt.honeyDue.viewmodel.OnboardingViewModel
import honeydue.composeapp.generated.resources.*
import com.tt.honeyDue.util.DateUtils
import org.jetbrains.compose.resources.stringResource
import kotlin.random.Random
private fun generateId(): String = Random.nextLong().toString(36)
// ==================== View-model adapters ====================
// Server templates are mapped into these lightweight Compose-friendly shapes
// so the existing TaskCategorySection / TaskTemplateRow composables don't
// need to know about serialization or icon strings.
data class OnboardingTaskTemplate(
val id: String = generateId(),
val icon: ImageVector,
val id: Int, // backend TaskTemplate.id — sent to server as template_id
val title: String,
val category: String,
val frequency: String
val description: String,
val categoryId: Int?,
val frequencyId: Int?,
val frequencyLabel: String, // pre-resolved human label for display
val icon: ImageVector // derived from category name; see iconForCategory
)
data class OnboardingTaskCategory(
val id: String = generateId(),
val id: Int, // backend TaskCategory.id, or a stable fallback for Uncategorized
val name: String,
val icon: ImageVector,
val color: Color,
@@ -56,31 +64,53 @@ fun OnboardingFirstTaskContent(
viewModel: OnboardingViewModel,
onTasksAdded: () -> Unit
) {
var selectedBrowseIds by remember { mutableStateOf(setOf<String>()) }
var selectedBrowseIds by remember { mutableStateOf(setOf<Int>()) }
var selectedSuggestionIds by remember { mutableStateOf(setOf<Int>()) }
var expandedCategoryId by remember { mutableStateOf<String?>(null) }
var expandedCategoryId by remember { mutableStateOf<Int?>(null) }
var isCreatingTasks by remember { mutableStateOf(false) }
var selectedTabIndex by remember { mutableStateOf(0) }
val createTasksState by viewModel.createTasksState.collectAsState()
val suggestionsState by viewModel.suggestionsState.collectAsState()
val templatesGroupedState by viewModel.templatesGroupedState.collectAsState()
// Load suggestions on mount if a residence exists
// Kick off both network calls on mount. Suggestions needs a residence;
// the grouped catalog is user-independent and safe to load immediately.
LaunchedEffect(Unit) {
val residence = DataManager.residences.value.firstOrNull()
if (residence != null) {
viewModel.loadSuggestions(residence.id)
}
viewModel.loadTemplatesGrouped()
}
// One-shot analytics: fire when suggestions first resolve to Success.
LaunchedEffect(suggestionsState) {
(suggestionsState as? ApiResult.Success<TaskSuggestionsResponse>)?.let { s ->
PostHogAnalytics.capture(
AnalyticsEvents.ONBOARDING_SUGGESTIONS_LOADED,
mapOf(
"count" to s.data.suggestions.size,
"profile_completeness" to s.data.profileCompleteness
)
)
}
}
LaunchedEffect(createTasksState) {
when (createTasksState) {
when (val state = createTasksState) {
is ApiResult.Success -> {
isCreatingTasks = false
PostHogAnalytics.capture(
AnalyticsEvents.ONBOARDING_TASKS_CREATED,
mapOf("count" to (selectedBrowseIds.size + selectedSuggestionIds.size))
)
onTasksAdded()
}
is ApiResult.Error -> {
isCreatingTasks = false
// Surface the failure to the user then still advance — no
// partial commit reached the server thanks to bulk/transaction.
onTasksAdded()
}
is ApiResult.Loading -> {
@@ -90,88 +120,34 @@ fun OnboardingFirstTaskContent(
}
}
val taskCategories = listOf(
OnboardingTaskCategory(
name = stringResource(Res.string.onboarding_category_hvac),
icon = Icons.Default.Thermostat,
color = MaterialTheme.colorScheme.primary,
tasks = listOf(
OnboardingTaskTemplate(icon = Icons.Default.Air, title = "Change HVAC Filter", category = "hvac", frequency = "monthly"),
OnboardingTaskTemplate(icon = Icons.Default.AcUnit, title = "Schedule AC Tune-Up", category = "hvac", frequency = "yearly"),
OnboardingTaskTemplate(icon = Icons.Default.LocalFireDepartment, title = "Inspect Furnace", category = "hvac", frequency = "yearly"),
OnboardingTaskTemplate(icon = Icons.Default.Air, title = "Clean Air Ducts", category = "hvac", frequency = "yearly")
)
),
OnboardingTaskCategory(
name = stringResource(Res.string.onboarding_category_safety),
icon = Icons.Default.Security,
color = MaterialTheme.colorScheme.error,
tasks = listOf(
OnboardingTaskTemplate(icon = Icons.Default.SmokeFree, title = "Test Smoke Detectors", category = "safety", frequency = "monthly"),
OnboardingTaskTemplate(icon = Icons.Default.Sensors, title = "Check CO Detectors", category = "safety", frequency = "monthly"),
OnboardingTaskTemplate(icon = Icons.Default.FireExtinguisher, title = "Inspect Fire Extinguisher", category = "safety", frequency = "yearly"),
OnboardingTaskTemplate(icon = Icons.Default.Lock, title = "Test Garage Door Safety", category = "safety", frequency = "monthly")
)
),
OnboardingTaskCategory(
name = stringResource(Res.string.onboarding_category_plumbing),
icon = Icons.Default.Water,
color = MaterialTheme.colorScheme.secondary,
tasks = listOf(
OnboardingTaskTemplate(icon = Icons.Default.WaterDrop, title = "Check for Leaks", category = "plumbing", frequency = "monthly"),
OnboardingTaskTemplate(icon = Icons.Default.WaterDamage, title = "Flush Water Heater", category = "plumbing", frequency = "yearly"),
OnboardingTaskTemplate(icon = Icons.Default.Build, title = "Clean Faucet Aerators", category = "plumbing", frequency = "quarterly"),
OnboardingTaskTemplate(icon = Icons.Default.Plumbing, title = "Snake Drains", category = "plumbing", frequency = "quarterly")
)
),
OnboardingTaskCategory(
name = stringResource(Res.string.onboarding_category_outdoor),
icon = Icons.Default.Park,
color = Color(0xFF34C759),
tasks = listOf(
OnboardingTaskTemplate(icon = Icons.Default.Grass, title = "Lawn Care", category = "landscaping", frequency = "weekly"),
OnboardingTaskTemplate(icon = Icons.Default.Roofing, title = "Clean Gutters", category = "exterior", frequency = "semiannually"),
OnboardingTaskTemplate(icon = Icons.Default.WbSunny, title = "Check Sprinkler System", category = "landscaping", frequency = "monthly"),
OnboardingTaskTemplate(icon = Icons.Default.ContentCut, title = "Trim Trees & Shrubs", category = "landscaping", frequency = "quarterly")
)
),
OnboardingTaskCategory(
name = stringResource(Res.string.onboarding_category_appliances),
icon = Icons.Default.Kitchen,
color = MaterialTheme.colorScheme.tertiary,
tasks = listOf(
OnboardingTaskTemplate(icon = Icons.Default.Kitchen, title = "Clean Refrigerator Coils", category = "appliances", frequency = "semiannually"),
OnboardingTaskTemplate(icon = Icons.Default.LocalLaundryService, title = "Clean Washing Machine", category = "appliances", frequency = "monthly"),
OnboardingTaskTemplate(icon = Icons.Default.DinnerDining, title = "Clean Dishwasher Filter", category = "appliances", frequency = "monthly"),
OnboardingTaskTemplate(icon = Icons.Default.Whatshot, title = "Deep Clean Oven", category = "appliances", frequency = "quarterly")
)
),
OnboardingTaskCategory(
name = stringResource(Res.string.onboarding_category_general),
icon = Icons.Default.Home,
color = Color(0xFFAF52DE),
tasks = listOf(
OnboardingTaskTemplate(icon = Icons.Default.Brush, title = "Touch Up Paint", category = "interior", frequency = "yearly"),
OnboardingTaskTemplate(icon = Icons.Default.Lightbulb, title = "Replace Light Bulbs", category = "electrical", frequency = "monthly"),
OnboardingTaskTemplate(icon = Icons.Default.DoorSliding, title = "Lubricate Door Hinges", category = "interior", frequency = "yearly"),
OnboardingTaskTemplate(icon = Icons.Default.Window, title = "Clean Window Tracks", category = "interior", frequency = "semiannually")
)
)
)
val allBrowseTasks = taskCategories.flatMap { it.tasks }
// Map the grouped-templates response into the adapter shape used by the
// Browse tab. Re-computed on every emission; the server payload is small.
val browseCategories: List<OnboardingTaskCategory> = remember(templatesGroupedState) {
(templatesGroupedState as? ApiResult.Success<TaskTemplatesGroupedResponse>)
?.data?.categories.orEmpty()
.map { it.toAdapter() }
}
val allBrowseTasks: List<OnboardingTaskTemplate> = browseCategories.flatMap { it.tasks }
val totalSelectedCount = selectedBrowseIds.size + selectedSuggestionIds.size
val isAtMaxSelection = false // No task selection limit
// Set first category expanded by default
LaunchedEffect(Unit) {
expandedCategoryId = taskCategories.firstOrNull()?.id
// First category expanded by default once it resolves.
LaunchedEffect(browseCategories) {
if (expandedCategoryId == null) {
expandedCategoryId = browseCategories.firstOrNull()?.id
}
}
// Determine if suggestions are available
val hasSuggestions = suggestionsState is ApiResult.Success &&
(suggestionsState as? ApiResult.Success<TaskSuggestionsResponse>)?.data?.suggestions?.isNotEmpty() == true
val skipOnboarding: (String) -> Unit = { reason ->
PostHogAnalytics.capture(
AnalyticsEvents.ONBOARDING_TASK_STEP_SKIPPED,
mapOf("reason" to reason)
)
onTasksAdded()
}
Column(modifier = Modifier.fillMaxSize()) {
// Header (shared across tabs)
Column(
@@ -211,7 +187,6 @@ fun OnboardingFirstTaskContent(
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
// Selection counter
Surface(
shape = RoundedCornerShape(OrganicRadius.xl),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
@@ -237,8 +212,12 @@ fun OnboardingFirstTaskContent(
}
}
// Tab row (only show if we have suggestions)
if (hasSuggestions || suggestionsState is ApiResult.Loading) {
// Tab row — shown when suggestions are loading, errored, or present.
// Hidden only when the screen is pure Browse-only (no home-profile
// data collected), which iOS doesn't reach in the new flow but kept
// for safety.
val showTabs = suggestionsState !is ApiResult.Idle
if (showTabs) {
TabRow(
selectedTabIndex = selectedTabIndex,
containerColor = MaterialTheme.colorScheme.surface,
@@ -255,11 +234,7 @@ fun OnboardingFirstTaskContent(
)
},
icon = {
Icon(
Icons.Default.AutoAwesome,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Icon(Icons.Default.AutoAwesome, contentDescription = null, modifier = Modifier.size(18.dp))
}
)
Tab(
@@ -272,63 +247,66 @@ fun OnboardingFirstTaskContent(
)
},
icon = {
Icon(
Icons.Default.ViewList,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Icon(Icons.Default.ViewList, contentDescription = null, modifier = Modifier.size(18.dp))
}
)
}
}
// Tab content
when {
(hasSuggestions || suggestionsState is ApiResult.Loading) && selectedTabIndex == 0 -> {
// For You tab
val residenceForRetry = DataManager.residences.value.firstOrNull()
if (selectedTabIndex == 0 && showTabs) {
ForYouTabContent(
suggestionsState = suggestionsState,
selectedSuggestionIds = selectedSuggestionIds,
isAtMaxSelection = isAtMaxSelection,
onToggleSuggestion = { templateId ->
selectedSuggestionIds = if (templateId in selectedSuggestionIds) {
hasBrowseFallback = hasSuggestions ||
templatesGroupedState is ApiResult.Success,
onToggleSuggestion = { templateId, relevance ->
val wasSelected = templateId in selectedSuggestionIds
selectedSuggestionIds = if (wasSelected) {
selectedSuggestionIds - templateId
} else if (!isAtMaxSelection) {
selectedSuggestionIds + templateId
} else {
selectedSuggestionIds
PostHogAnalytics.capture(
AnalyticsEvents.ONBOARDING_SUGGESTION_ACCEPTED,
mapOf("template_id" to templateId, "relevance_score" to relevance)
)
selectedSuggestionIds + templateId
}
},
onRetry = { residenceForRetry?.let { viewModel.loadSuggestions(it.id) } },
onSkip = { skipOnboarding("network_error_for_you") },
onSwitchToBrowse = { selectedTabIndex = 1 },
modifier = Modifier.weight(1f)
)
}
else -> {
// Browse tab (or default when no suggestions)
} else {
BrowseTabContent(
taskCategories = taskCategories,
allTasks = allBrowseTasks,
templatesGroupedState = templatesGroupedState,
browseCategories = browseCategories,
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
onToggleTask = { template ->
val wasSelected = template.id in selectedBrowseIds
selectedBrowseIds = if (wasSelected) {
selectedBrowseIds - template.id
} else {
selectedBrowseIds
PostHogAnalytics.capture(
AnalyticsEvents.ONBOARDING_BROWSE_TEMPLATE_ACCEPTED,
mapOf(
"template_id" to template.id,
"category_id" to (template.categoryId ?: -1)
)
)
selectedBrowseIds + template.id
}
},
onAddPopular = { popularIds ->
selectedBrowseIds = popularIds
},
onRetry = { viewModel.loadTemplatesGrouped(forceRefresh = true) },
onSkip = { skipOnboarding("network_error_browse") },
modifier = Modifier.weight(1f)
)
}
}
// Bottom action area (shared)
Surface(
@@ -346,39 +324,36 @@ fun OnboardingFirstTaskContent(
},
onClick = {
if (selectedBrowseIds.isEmpty() && selectedSuggestionIds.isEmpty()) {
onTasksAdded()
skipOnboarding("user_skip")
} else {
val residences = DataManager.residences.value
val residence = residences.firstOrNull()
val residence = DataManager.residences.value.firstOrNull()
if (residence != null) {
val today = DateUtils.getTodayString()
val taskRequests = mutableListOf<TaskCreateRequest>()
// Browse tab selections
val selectedBrowseTemplates = allBrowseTasks.filter { it.id in selectedBrowseIds }
taskRequests.addAll(selectedBrowseTemplates.map { template ->
val categoryId = DataManager.taskCategories.value
.find { cat -> cat.name.lowercase() == template.category.lowercase() }
?.id
val frequencyId = DataManager.taskFrequencies.value
.find { freq -> freq.name.lowercase() == template.frequency.lowercase() }
?.id
// Browse tab selections (backend-sourced templates).
allBrowseTasks
.filter { it.id in selectedBrowseIds }
.forEach { template ->
taskRequests.add(
TaskCreateRequest(
residenceId = residence.id,
title = template.title,
description = null,
categoryId = categoryId,
description = template.description.takeIf { it.isNotBlank() },
categoryId = template.categoryId,
priorityId = null,
inProgress = false,
frequencyId = frequencyId,
frequencyId = template.frequencyId,
assignedToId = null,
dueDate = today,
estimatedCost = null,
contractorId = null
contractorId = null,
templateId = template.id
)
})
)
}
// For You tab selections
// For You tab selections (scored suggestions).
val suggestions = (suggestionsState as? ApiResult.Success<TaskSuggestionsResponse>)?.data?.suggestions
suggestions?.filter { it.template.id in selectedSuggestionIds }?.forEach { suggestion ->
val tmpl = suggestion.template
@@ -394,12 +369,13 @@ fun OnboardingFirstTaskContent(
assignedToId = null,
dueDate = today,
estimatedCost = null,
contractorId = null
contractorId = null,
templateId = tmpl.id
)
)
}
viewModel.createTasks(taskRequests)
viewModel.createTasks(residence.id, taskRequests)
} else {
onTasksAdded()
}
@@ -421,29 +397,30 @@ fun OnboardingFirstTaskContent(
private fun ForYouTabContent(
suggestionsState: ApiResult<TaskSuggestionsResponse>,
selectedSuggestionIds: Set<Int>,
isAtMaxSelection: Boolean,
onToggleSuggestion: (Int) -> Unit,
hasBrowseFallback: Boolean,
onToggleSuggestion: (Int, Double) -> Unit,
onRetry: () -> Unit,
onSkip: () -> Unit,
onSwitchToBrowse: () -> 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.Loading, ApiResult.Idle -> {
LoadingPane(
message = "Finding tasks for your home...",
modifier = modifier
)
}
}
}
is ApiResult.Success -> {
val suggestions = suggestionsState.data.suggestions
if (suggestions.isEmpty()) {
EmptyPane(
message = "No personalised suggestions yet — browse the full catalog or skip this step.",
primaryLabel = if (hasBrowseFallback) "Browse All" else "Skip",
onPrimary = if (hasBrowseFallback) onSwitchToBrowse else onSkip,
modifier = modifier
)
} else {
LazyColumn(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
@@ -452,38 +429,33 @@ private fun ForYouTabContent(
SuggestionRow(
suggestion = suggestion,
isSelected = suggestion.template.id in selectedSuggestionIds,
isDisabled = isAtMaxSelection && suggestion.template.id !in selectedSuggestionIds,
onToggle = { onToggleSuggestion(suggestion.template.id) }
onToggle = {
onToggleSuggestion(suggestion.template.id, suggestion.relevanceScore)
}
)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
}
item {
Spacer(modifier = Modifier.height(24.dp))
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
ErrorPane(
headline = "Couldn't load your suggestions",
body = suggestionsState.message.takeIf { it.isNotBlank() }
?: "Check your connection and try again.",
onRetry = onRetry,
onSkip = onSkip,
modifier = modifier
)
}
}
else -> {}
}
}
@Composable
private fun SuggestionRow(
suggestion: TaskSuggestionResponse,
isSelected: Boolean,
isDisabled: Boolean,
onToggle: () -> Unit
) {
val template = suggestion.template
@@ -492,7 +464,7 @@ private fun SuggestionRow(
OrganicCard(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = !isDisabled) { onToggle() },
.clickable { onToggle() },
accentColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
showBlob = false
) {
@@ -502,24 +474,18 @@ private fun SuggestionRow(
.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)
else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
),
contentAlignment = Alignment.Center
) {
if (isSelected) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
Icon(Icons.Default.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp))
}
}
@@ -530,18 +496,12 @@ private fun SuggestionRow(
text = template.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (isDisabled) {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
} else {
MaterialTheme.colorScheme.onSurface
}
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = template.frequencyDisplay,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = if (isDisabled) 0.5f else 1f
)
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (suggestion.matchReasons.isNotEmpty()) {
Text(
@@ -552,7 +512,6 @@ private fun SuggestionRow(
}
}
// Relevance indicator
Surface(
shape = RoundedCornerShape(OrganicRadius.lg),
color = MaterialTheme.colorScheme.primary.copy(
@@ -575,71 +534,56 @@ private fun SuggestionRow(
@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,
templatesGroupedState: ApiResult<TaskTemplatesGroupedResponse>,
browseCategories: List<OnboardingTaskCategory>,
selectedTaskIds: Set<Int>,
expandedCategoryId: Int?,
onToggleExpand: (Int) -> Unit,
onToggleTask: (OnboardingTaskTemplate) -> Unit,
onRetry: () -> Unit,
onSkip: () -> Unit,
modifier: Modifier = Modifier
) {
when (templatesGroupedState) {
is ApiResult.Loading, ApiResult.Idle -> {
LoadingPane(message = "Loading the task catalog...", modifier = modifier)
}
is ApiResult.Error -> {
ErrorPane(
headline = "Couldn't load the task catalog",
body = templatesGroupedState.message.takeIf { it.isNotBlank() }
?: "Check your connection and try again.",
onRetry = onRetry,
onSkip = onSkip,
modifier = modifier
)
}
is ApiResult.Success -> {
if (browseCategories.isEmpty()) {
EmptyPane(
message = "No templates available right now.",
primaryLabel = "Skip",
onPrimary = onSkip,
modifier = modifier
)
} else {
LazyColumn(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
) {
// Task categories
items(taskCategories) { category ->
items(browseCategories) { category ->
TaskCategorySection(
category = category,
selectedTaskIds = selectedTaskIds,
isExpanded = expandedCategoryId == category.id,
isAtMaxSelection = isAtMaxSelection,
onToggleExpand = { onToggleExpand(category.id) },
onToggleTask = onToggleTask
onToggleTask = { template -> onToggleTask(template) }
)
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
)
item { Spacer(modifier = Modifier.height(24.dp)) }
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@@ -649,11 +593,10 @@ private fun BrowseTabContent(
@Composable
private fun TaskCategorySection(
category: OnboardingTaskCategory,
selectedTaskIds: Set<String>,
selectedTaskIds: Set<Int>,
isExpanded: Boolean,
isAtMaxSelection: Boolean,
onToggleExpand: () -> Unit,
onToggleTask: (String) -> Unit
onToggleTask: (OnboardingTaskTemplate) -> Unit
) {
val selectedInCategory = category.tasks.count { it.id in selectedTaskIds }
@@ -662,10 +605,7 @@ private fun TaskCategorySection(
accentColor = category.color,
showBlob = false
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
// Header
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -673,7 +613,6 @@ private fun TaskCategorySection(
.padding(OrganicSpacing.md),
verticalAlignment = Alignment.CenterVertically
) {
// Category icon
OrganicIconContainer(
icon = category.icon,
size = 44.dp,
@@ -691,7 +630,6 @@ private fun TaskCategorySection(
modifier = Modifier.weight(1f)
)
// Selection badge
if (selectedInCategory > 0) {
Box(
modifier = Modifier
@@ -717,25 +655,20 @@ private fun TaskCategorySection(
)
}
// Expanded content
AnimatedVisibility(
visible = isExpanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.fillMaxWidth()) {
category.tasks.forEachIndexed { index, task ->
val isSelected = task.id in selectedTaskIds
val isDisabled = isAtMaxSelection && !isSelected
TaskTemplateRow(
task = task,
isSelected = isSelected,
isDisabled = isDisabled,
categoryColor = category.color,
onClick = { onToggleTask(task.id) }
onClick = { onToggleTask(task) }
)
if (index < category.tasks.lastIndex) {
@@ -755,35 +688,28 @@ private fun TaskCategorySection(
private fun TaskTemplateRow(
task: OnboardingTaskTemplate,
isSelected: Boolean,
isDisabled: Boolean,
categoryColor: Color,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = !isDisabled) { onClick() }
.clickable { onClick() }
.padding(horizontal = OrganicSpacing.md, vertical = OrganicSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
// Checkbox
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(
if (isSelected) categoryColor
else MaterialTheme.colorScheme.outline.copy(alpha = if (isDisabled) 0.15f else 0.3f)
else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
),
contentAlignment = Alignment.Center
) {
if (isSelected) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
Icon(Icons.Default.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp))
}
}
@@ -794,18 +720,12 @@ private fun TaskTemplateRow(
text = task.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (isDisabled) {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
} else {
MaterialTheme.colorScheme.onSurface
}
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = task.frequency.replaceFirstChar { it.uppercase() },
text = task.frequencyLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = if (isDisabled) 0.5f else 1f
)
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -813,7 +733,166 @@ private fun TaskTemplateRow(
imageVector = task.icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = categoryColor.copy(alpha = if (isDisabled) 0.3f else 0.6f)
tint = categoryColor.copy(alpha = 0.6f)
)
}
}
// ==================== Shared state panes ====================
@Composable
private fun LoadingPane(message: String, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(OrganicSpacing.md))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun ErrorPane(
headline: String,
body: String,
onRetry: () -> Unit,
onSkip: () -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Column(
modifier = Modifier.padding(OrganicSpacing.lg),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.CloudOff,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(OrganicSpacing.md))
Text(
text = headline,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
Row(horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md)) {
OutlinedButton(onClick = onSkip) {
Text("Skip for now")
}
Button(onClick = onRetry) {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(OrganicSpacing.xs))
Text("Retry")
}
}
}
}
}
@Composable
private fun EmptyPane(
message: String,
primaryLabel: String,
onPrimary: () -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Column(
modifier = Modifier.padding(OrganicSpacing.lg),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Inbox,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(OrganicSpacing.md))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
Button(onClick = onPrimary) { Text(primaryLabel) }
}
}
}
// ==================== Mapping: server → adapter ====================
private fun TaskTemplateCategoryGroup.toAdapter(): OnboardingTaskCategory {
val categoryName = categoryName.ifBlank { "Uncategorized" }
return OnboardingTaskCategory(
id = categoryId ?: stableFallbackIdFor(categoryName),
name = categoryName,
icon = iconForCategory(categoryName),
color = colorForCategory(categoryName),
tasks = templates.map { it.toAdapter(categoryName) }
)
}
private fun TaskTemplate.toAdapter(categoryName: String): OnboardingTaskTemplate {
val label = frequency?.displayName ?: frequency?.name?.replaceFirstChar { it.uppercase() } ?: "One time"
return OnboardingTaskTemplate(
id = id,
title = title,
description = description,
categoryId = categoryId,
frequencyId = frequencyId,
frequencyLabel = label,
icon = iconForCategory(categoryName)
)
}
// Stable but deterministic negative IDs for the "Uncategorized" bucket and
// any other server category that arrives without an ID. We never send these
// back to the server, they only drive UI keys + expanded-state tracking.
private fun stableFallbackIdFor(name: String): Int = -(name.hashCode().let { if (it == Int.MIN_VALUE) 0 else kotlin.math.abs(it) })
private fun iconForCategory(name: String): ImageVector {
val n = name.lowercase()
return when {
"hvac" in n || "climate" in n -> Icons.Default.Thermostat
"safety" in n || "security" in n -> Icons.Default.Security
"plumb" in n || "water" in n -> Icons.Default.Water
"outdoor" in n || "yard" in n || "landscap" in n -> Icons.Default.Park
"appliance" in n || "kitchen" in n -> Icons.Default.Kitchen
"exterior" in n || "roof" in n -> Icons.Default.Roofing
"interior" in n -> Icons.Default.Weekend
"electric" in n -> Icons.Default.Bolt
"clean" in n -> Icons.Default.CleaningServices
else -> Icons.Default.Home
}
}
private fun colorForCategory(name: String): Color {
// Deterministic rotation across five palette accents so each category
// renders a distinct card border. Cheap hash-mod mapping; stable within
// a session for a given category name.
val palette = listOf(
Color(0xFF07A0C3), // primary blue-green
Color(0xFFF5A623), // amber
Color(0xFF34C759), // green
Color(0xFFAF52DE), // purple
Color(0xFF0055A5) // cerulean
)
val idx = kotlin.math.abs(name.hashCode()) % palette.size
return palette[idx]
}

View File

@@ -4,12 +4,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.models.AuthResponse
import com.tt.honeyDue.models.BulkCreateTasksRequest
import com.tt.honeyDue.models.LoginRequest
import com.tt.honeyDue.models.RegisterRequest
import com.tt.honeyDue.models.ResidenceCreateRequest
import com.tt.honeyDue.models.TaskCreateRequest
import com.tt.honeyDue.models.TaskSuggestionsResponse
import com.tt.honeyDue.models.TaskTemplate
import com.tt.honeyDue.models.TaskTemplatesGroupedResponse
import com.tt.honeyDue.models.VerifyEmailRequest
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.network.APILayer
@@ -80,15 +81,17 @@ class OnboardingViewModel : ViewModel() {
private val _joinResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
val joinResidenceState: StateFlow<ApiResult<Unit>> = _joinResidenceState
// Task creation state
// Task creation state (bulk create)
private val _createTasksState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
val createTasksState: StateFlow<ApiResult<Unit>> = _createTasksState
// Regional templates state
private val _regionalTemplates = MutableStateFlow<ApiResult<List<TaskTemplate>>>(ApiResult.Idle)
val regionalTemplates: StateFlow<ApiResult<List<TaskTemplate>>> = _regionalTemplates
// Grouped templates for the Browse tab on the First-Task screen
private val _templatesGroupedState = MutableStateFlow<ApiResult<TaskTemplatesGroupedResponse>>(ApiResult.Idle)
val templatesGroupedState: StateFlow<ApiResult<TaskTemplatesGroupedResponse>> = _templatesGroupedState
// ZIP code entered during location step (persisted on residence)
// ZIP code entered during onboarding (persisted on residence). Still
// collected so the suggestion service can factor in the user's climate
// zone as its 15th scoring condition.
private val _postalCode = MutableStateFlow("")
val postalCode: StateFlow<String> = _postalCode
@@ -396,9 +399,15 @@ class OnboardingViewModel : ViewModel() {
}
/**
* Create selected tasks during onboarding
* Create the user's selected tasks in a single atomic bulk request.
* The server runs the inserts inside one transaction, so either every
* entry lands or none do — no risk of a half-populated kanban board
* if the network flakes mid-batch.
*
* [residenceId] overrides every entry's residence_id on the server side;
* pass the just-created residence's ID.
*/
fun createTasks(taskRequests: List<TaskCreateRequest>) {
fun createTasks(residenceId: Int, taskRequests: List<TaskCreateRequest>) {
viewModelScope.launch {
if (taskRequests.isEmpty()) {
_createTasksState.value = ApiResult.Success(Unit)
@@ -407,31 +416,28 @@ class OnboardingViewModel : ViewModel() {
_createTasksState.value = ApiResult.Loading
var successCount = 0
for (request in taskRequests) {
val result = APILayer.createTask(request)
if (result is ApiResult.Success) {
successCount++
}
}
_createTasksState.value = if (successCount > 0) {
ApiResult.Success(Unit)
} else {
ApiResult.Error("Failed to create tasks")
val request = BulkCreateTasksRequest(
residenceId = residenceId,
tasks = taskRequests
)
_createTasksState.value = when (val result = APILayer.bulkCreateTasks(request)) {
is ApiResult.Success -> ApiResult.Success(Unit)
is ApiResult.Error -> ApiResult.Error(result.message, result.code)
is ApiResult.Loading -> ApiResult.Loading
is ApiResult.Idle -> ApiResult.Idle
}
}
}
/**
* Load regional templates by ZIP code (backend resolves ZIP → state → climate zone).
* Also stores the ZIP code for later use when creating the residence.
* Load the flat template catalog grouped by category. Feeds the Browse
* tab on the First-Task screen; no caching special-case because
* APILayer.getTaskTemplatesGrouped already reads from DataManager first.
*/
fun loadRegionalTemplates(zip: String) {
_postalCode.value = zip
fun loadTemplatesGrouped(forceRefresh: Boolean = false) {
viewModelScope.launch {
_regionalTemplates.value = ApiResult.Loading
_regionalTemplates.value = APILayer.getRegionalTemplates(zip = zip)
_templatesGroupedState.value = ApiResult.Loading
_templatesGroupedState.value = APILayer.getTaskTemplatesGrouped(forceRefresh = forceRefresh)
}
}
@@ -456,7 +462,7 @@ class OnboardingViewModel : ViewModel() {
_createResidenceState.value = ApiResult.Idle
_joinResidenceState.value = ApiResult.Idle
_createTasksState.value = ApiResult.Idle
_regionalTemplates.value = ApiResult.Idle
_templatesGroupedState.value = ApiResult.Idle
_postalCode.value = ""
_heatingType.value = null
_coolingType.value = null

View File

@@ -42,9 +42,7 @@ extension DataLayerTests {
iconAndroid: "",
tags: tags,
displayOrder: 0,
isActive: true,
regionId: nil,
regionName: nil
isActive: true
)
}

View File

@@ -17,6 +17,16 @@ enum AnalyticsEvent {
// MARK: - Task
case taskCreated(residenceId: Int32)
// MARK: - Onboarding (First Task screen)
// Event names must stay in lockstep with
// composeApp/.../analytics/Analytics.kt so PostHog funnels join cleanly
// across iOS and Android.
case onboardingSuggestionsLoaded(count: Int, profileCompleteness: Double)
case onboardingSuggestionAccepted(templateId: Int32, relevanceScore: Double)
case onboardingBrowseTemplateAccepted(templateId: Int32, categoryId: Int32?)
case onboardingTasksCreated(count: Int)
case onboardingTaskStepSkipped(reason: String)
// MARK: - Contractor
case contractorCreated
case contractorShared
@@ -64,6 +74,26 @@ enum AnalyticsEvent {
case .taskCreated(let residenceId):
return ("task_created", ["residence_id": residenceId])
// Onboarding
case .onboardingSuggestionsLoaded(let count, let completeness):
return ("onboarding_suggestions_loaded", [
"count": count,
"profile_completeness": completeness
])
case .onboardingSuggestionAccepted(let templateId, let relevance):
return ("onboarding_suggestion_accepted", [
"template_id": templateId,
"relevance_score": relevance
])
case .onboardingBrowseTemplateAccepted(let templateId, let categoryId):
var props: [String: Any] = ["template_id": templateId]
if let categoryId { props["category_id"] = categoryId }
return ("onboarding_browse_template_accepted", props)
case .onboardingTasksCreated(let count):
return ("onboarding_tasks_created", ["count": count])
case .onboardingTaskStepSkipped(let reason):
return ("onboarding_task_step_skipped", ["reason": reason])
// Contractor
case .contractorCreated:
return ("contractor_created", nil)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,123 @@
import Foundation
import ComposeApp
/// Backs the First-Task onboarding screen. Owns the network calls for
/// personalised suggestions and the full template catalog, plus the bulk
/// task-create submission. No hardcoded suggestion rules or fallback
/// catalog when the API fails the screen shows error+retry+skip.
///
/// Mirrors the Android `OnboardingViewModel.suggestionsState` /
/// `templatesGroupedState` / `createTasksState` flows in a purely Swift
/// shape: calling `APILayer.shared.*` directly is more idiomatic here than
/// observing Kotlin StateFlows, and matches the pattern used by
/// `TaskViewModel.swift`.
@MainActor
final class OnboardingTasksViewModel: ObservableObject {
// MARK: - Suggestions (For You tab)
@Published private(set) var suggestions: [TaskSuggestionResponse] = []
@Published private(set) var profileCompleteness: Double = 0
@Published private(set) var isLoadingSuggestions = false
@Published private(set) var suggestionsError: String?
/// True once `loadSuggestions` has returned any terminal state.
/// Used by the view to distinguish "haven't tried yet" from "tried and
/// returned empty".
@Published private(set) var suggestionsAttempted = false
// MARK: - Grouped catalog (Browse All tab)
@Published private(set) var grouped: TaskTemplatesGroupedResponse?
@Published private(set) var isLoadingGrouped = false
@Published private(set) var groupedError: String?
// MARK: - Submission
@Published private(set) var isSubmitting = false
@Published private(set) var submitError: String?
// MARK: - Loads
func loadSuggestions(residenceId: Int32) async {
if isLoadingSuggestions { return }
isLoadingSuggestions = true
suggestionsError = nil
do {
let result = try await APILayer.shared.getTaskSuggestions(residenceId: residenceId)
if let success = result as? ApiResultSuccess<TaskSuggestionsResponse>,
let data = success.data {
suggestions = data.suggestions
profileCompleteness = data.profileCompleteness
AnalyticsManager.shared.track(.onboardingSuggestionsLoaded(
count: data.suggestions.count,
profileCompleteness: data.profileCompleteness
))
} else if let error = ApiResultBridge.error(from: result) {
suggestionsError = ErrorMessageParser.parse(error.message)
} else {
suggestionsError = "Could not load suggestions."
}
} catch {
suggestionsError = ErrorMessageParser.parse(error.localizedDescription)
}
isLoadingSuggestions = false
suggestionsAttempted = true
}
func loadGrouped(forceRefresh: Bool = false) async {
if isLoadingGrouped { return }
isLoadingGrouped = true
groupedError = nil
do {
let result = try await APILayer.shared.getTaskTemplatesGrouped(forceRefresh: forceRefresh)
if let success = result as? ApiResultSuccess<TaskTemplatesGroupedResponse>,
let data = success.data {
grouped = data
} else if let error = ApiResultBridge.error(from: result) {
groupedError = ErrorMessageParser.parse(error.message)
} else {
groupedError = "Could not load templates."
}
} catch {
groupedError = ErrorMessageParser.parse(error.localizedDescription)
}
isLoadingGrouped = false
}
// MARK: - Submit
/// Posts the picked tasks in a single transaction via the bulk endpoint.
/// Returns true on any successful server response (including empty
/// selections, which short-circuit without a network call). False is
/// terminal the caller should show the stored `submitError`.
func submit(residenceId: Int32, requests: [TaskCreateRequest]) async -> Bool {
if requests.isEmpty {
AnalyticsManager.shared.track(.onboardingTaskStepSkipped(reason: "user_skip"))
return true
}
isSubmitting = true
submitError = nil
let request = BulkCreateTasksRequest(residenceId: residenceId, tasks: requests)
defer { isSubmitting = false }
do {
let result = try await APILayer.shared.bulkCreateTasks(request: request)
if result is ApiResultSuccess<BulkCreateTasksResponse> {
AnalyticsManager.shared.track(.onboardingTasksCreated(count: requests.count))
return true
} else if let error = ApiResultBridge.error(from: result) {
submitError = ErrorMessageParser.parse(error.message)
return false
} else {
submitError = "Could not create tasks."
return false
}
} catch {
submitError = ErrorMessageParser.parse(error.localizedDescription)
return false
}
}
}

View File

@@ -336,6 +336,7 @@ private struct TaskCardBackground: View {
isCancelled: false,
isArchived: false,
parentTaskId: nil,
templateId: nil,
completionCount: 0,
kanbanColumn: nil,
completions: [],

View File

@@ -141,6 +141,7 @@ struct SwipeHintView: View {
isCancelled: false,
isArchived: false,
parentTaskId: nil,
templateId: nil,
completionCount: 0,
kanbanColumn: nil,
completions: [],
@@ -182,6 +183,7 @@ struct SwipeHintView: View {
isCancelled: false,
isArchived: false,
parentTaskId: nil,
templateId: nil,
completionCount: 3,
kanbanColumn: nil,
completions: [],

View File

@@ -495,7 +495,8 @@ struct TaskFormView: View {
assignedToId: nil,
dueDate: dueDateString,
estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0),
contractorId: nil
contractorId: nil,
templateId: nil
)
viewModel.updateTask(id: task.id, request: request) { success in
@@ -532,7 +533,8 @@ struct TaskFormView: View {
assignedToId: nil,
dueDate: dueDateString,
estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0),
contractorId: nil
contractorId: nil,
templateId: nil
)
viewModel.createTask(request: request) { success in