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:
42
CLAUDE.md
42
CLAUDE.md
@@ -1219,6 +1219,48 @@ val CURRENT_ENV = Environment.DEV // or Environment.LOCAL
|
|||||||
3. Add method to relevant ViewModel that calls APILayer
|
3. Add method to relevant ViewModel that calls APILayer
|
||||||
4. Update UI to observe the new StateFlow
|
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
|
### Handling Platform-Specific Code
|
||||||
|
|
||||||
Use `expect/actual` pattern:
|
Use `expect/actual` pattern:
|
||||||
|
|||||||
@@ -36,6 +36,20 @@ object AnalyticsEvents {
|
|||||||
const val NEW_TASK_SCREEN_SHOWN = "new_task_screen_shown"
|
const val NEW_TASK_SCREEN_SHOWN = "new_task_screen_shown"
|
||||||
const val TASK_CREATED = "task_created"
|
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
|
// Contractor
|
||||||
const val CONTRACTOR_SCREEN_SHOWN = "contractor_screen_shown"
|
const val CONTRACTOR_SCREEN_SHOWN = "contractor_screen_shown"
|
||||||
const val NEW_CONTRACTOR_SCREEN_SHOWN = "new_contractor_screen_shown"
|
const val NEW_CONTRACTOR_SCREEN_SHOWN = "new_contractor_screen_shown"
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ data class TaskResponse(
|
|||||||
@SerialName("is_cancelled") val isCancelled: Boolean = false,
|
@SerialName("is_cancelled") val isCancelled: Boolean = false,
|
||||||
@SerialName("is_archived") val isArchived: Boolean = false,
|
@SerialName("is_archived") val isArchived: Boolean = false,
|
||||||
@SerialName("parent_task_id") val parentTaskId: Int? = null,
|
@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("completion_count") val completionCount: Int = 0,
|
||||||
@SerialName("kanban_column") val kanbanColumn: String? = null, // Which kanban column this task belongs to
|
@SerialName("kanban_column") val kanbanColumn: String? = null, // Which kanban column this task belongs to
|
||||||
// Note: Go API does not return completions inline with TaskResponse.
|
// 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("assigned_to_id") val assignedToId: Int? = null,
|
||||||
@SerialName("due_date") val dueDate: String? = null,
|
@SerialName("due_date") val dueDate: String? = null,
|
||||||
@SerialName("estimated_cost") val estimatedCost: Double? = 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
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ data class TaskTemplate(
|
|||||||
@SerialName("icon_android") val iconAndroid: String = "",
|
@SerialName("icon_android") val iconAndroid: String = "",
|
||||||
val tags: List<String> = emptyList(),
|
val tags: List<String> = emptyList(),
|
||||||
@SerialName("display_order") val displayOrder: Int = 0,
|
@SerialName("display_order") val displayOrder: Int = 0,
|
||||||
@SerialName("is_active") val isActive: Boolean = true,
|
@SerialName("is_active") val isActive: Boolean = true
|
||||||
@SerialName("region_id") val regionId: Int? = null,
|
|
||||||
@SerialName("region_name") val regionName: String? = null
|
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Human-readable frequency display
|
* Human-readable frequency display
|
||||||
|
|||||||
@@ -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> {
|
suspend fun updateTask(id: Int, request: TaskCreateRequest): ApiResult<TaskResponse> {
|
||||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||||
val result = taskApi.updateTask(token, id, request)
|
val result = taskApi.updateTask(token, id, request)
|
||||||
@@ -1200,15 +1217,6 @@ object APILayer {
|
|||||||
} ?: ApiResult.Error("Task template not found")
|
} ?: 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.
|
* Get personalized task suggestions for a residence based on its home profile.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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>> {
|
suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.put("$baseUrl/tasks/$id/") {
|
val response = client.put("$baseUrl/tasks/$id/") {
|
||||||
|
|||||||
@@ -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.
|
* Get personalized task suggestions for a residence based on its home profile.
|
||||||
* Requires authentication.
|
* Requires authentication.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,13 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.tt.honeyDue.data.DataManager
|
import com.tt.honeyDue.data.DataManager
|
||||||
import com.tt.honeyDue.models.AuthResponse
|
import com.tt.honeyDue.models.AuthResponse
|
||||||
|
import com.tt.honeyDue.models.BulkCreateTasksRequest
|
||||||
import com.tt.honeyDue.models.LoginRequest
|
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.TaskSuggestionsResponse
|
||||||
import com.tt.honeyDue.models.TaskTemplate
|
import com.tt.honeyDue.models.TaskTemplatesGroupedResponse
|
||||||
import com.tt.honeyDue.models.VerifyEmailRequest
|
import com.tt.honeyDue.models.VerifyEmailRequest
|
||||||
import com.tt.honeyDue.network.ApiResult
|
import com.tt.honeyDue.network.ApiResult
|
||||||
import com.tt.honeyDue.network.APILayer
|
import com.tt.honeyDue.network.APILayer
|
||||||
@@ -80,15 +81,17 @@ class OnboardingViewModel : ViewModel() {
|
|||||||
private val _joinResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
private val _joinResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
||||||
val joinResidenceState: StateFlow<ApiResult<Unit>> = _joinResidenceState
|
val joinResidenceState: StateFlow<ApiResult<Unit>> = _joinResidenceState
|
||||||
|
|
||||||
// Task creation state
|
// Task creation state (bulk create)
|
||||||
private val _createTasksState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
private val _createTasksState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
||||||
val createTasksState: StateFlow<ApiResult<Unit>> = _createTasksState
|
val createTasksState: StateFlow<ApiResult<Unit>> = _createTasksState
|
||||||
|
|
||||||
// Regional templates state
|
// Grouped templates for the Browse tab on the First-Task screen
|
||||||
private val _regionalTemplates = MutableStateFlow<ApiResult<List<TaskTemplate>>>(ApiResult.Idle)
|
private val _templatesGroupedState = MutableStateFlow<ApiResult<TaskTemplatesGroupedResponse>>(ApiResult.Idle)
|
||||||
val regionalTemplates: StateFlow<ApiResult<List<TaskTemplate>>> = _regionalTemplates
|
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("")
|
private val _postalCode = MutableStateFlow("")
|
||||||
val postalCode: StateFlow<String> = _postalCode
|
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 {
|
viewModelScope.launch {
|
||||||
if (taskRequests.isEmpty()) {
|
if (taskRequests.isEmpty()) {
|
||||||
_createTasksState.value = ApiResult.Success(Unit)
|
_createTasksState.value = ApiResult.Success(Unit)
|
||||||
@@ -407,31 +416,28 @@ class OnboardingViewModel : ViewModel() {
|
|||||||
|
|
||||||
_createTasksState.value = ApiResult.Loading
|
_createTasksState.value = ApiResult.Loading
|
||||||
|
|
||||||
var successCount = 0
|
val request = BulkCreateTasksRequest(
|
||||||
for (request in taskRequests) {
|
residenceId = residenceId,
|
||||||
val result = APILayer.createTask(request)
|
tasks = taskRequests
|
||||||
if (result is ApiResult.Success) {
|
)
|
||||||
successCount++
|
_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
|
||||||
_createTasksState.value = if (successCount > 0) {
|
is ApiResult.Idle -> ApiResult.Idle
|
||||||
ApiResult.Success(Unit)
|
|
||||||
} else {
|
|
||||||
ApiResult.Error("Failed to create tasks")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load regional templates by ZIP code (backend resolves ZIP → state → climate zone).
|
* Load the flat template catalog grouped by category. Feeds the Browse
|
||||||
* Also stores the ZIP code for later use when creating the residence.
|
* tab on the First-Task screen; no caching special-case because
|
||||||
|
* APILayer.getTaskTemplatesGrouped already reads from DataManager first.
|
||||||
*/
|
*/
|
||||||
fun loadRegionalTemplates(zip: String) {
|
fun loadTemplatesGrouped(forceRefresh: Boolean = false) {
|
||||||
_postalCode.value = zip
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_regionalTemplates.value = ApiResult.Loading
|
_templatesGroupedState.value = ApiResult.Loading
|
||||||
_regionalTemplates.value = APILayer.getRegionalTemplates(zip = zip)
|
_templatesGroupedState.value = APILayer.getTaskTemplatesGrouped(forceRefresh = forceRefresh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,7 +462,7 @@ class OnboardingViewModel : ViewModel() {
|
|||||||
_createResidenceState.value = ApiResult.Idle
|
_createResidenceState.value = ApiResult.Idle
|
||||||
_joinResidenceState.value = ApiResult.Idle
|
_joinResidenceState.value = ApiResult.Idle
|
||||||
_createTasksState.value = ApiResult.Idle
|
_createTasksState.value = ApiResult.Idle
|
||||||
_regionalTemplates.value = ApiResult.Idle
|
_templatesGroupedState.value = ApiResult.Idle
|
||||||
_postalCode.value = ""
|
_postalCode.value = ""
|
||||||
_heatingType.value = null
|
_heatingType.value = null
|
||||||
_coolingType.value = null
|
_coolingType.value = null
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ extension DataLayerTests {
|
|||||||
iconAndroid: "",
|
iconAndroid: "",
|
||||||
tags: tags,
|
tags: tags,
|
||||||
displayOrder: 0,
|
displayOrder: 0,
|
||||||
isActive: true,
|
isActive: true
|
||||||
regionId: nil,
|
|
||||||
regionName: nil
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ enum AnalyticsEvent {
|
|||||||
// MARK: - Task
|
// MARK: - Task
|
||||||
case taskCreated(residenceId: Int32)
|
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
|
// MARK: - Contractor
|
||||||
case contractorCreated
|
case contractorCreated
|
||||||
case contractorShared
|
case contractorShared
|
||||||
@@ -64,6 +74,26 @@ enum AnalyticsEvent {
|
|||||||
case .taskCreated(let residenceId):
|
case .taskCreated(let residenceId):
|
||||||
return ("task_created", ["residence_id": 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
|
// Contractor
|
||||||
case .contractorCreated:
|
case .contractorCreated:
|
||||||
return ("contractor_created", nil)
|
return ("contractor_created", nil)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
123
iosApp/iosApp/Onboarding/OnboardingTasksViewModel.swift
Normal file
123
iosApp/iosApp/Onboarding/OnboardingTasksViewModel.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -336,6 +336,7 @@ private struct TaskCardBackground: View {
|
|||||||
isCancelled: false,
|
isCancelled: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
parentTaskId: nil,
|
parentTaskId: nil,
|
||||||
|
templateId: nil,
|
||||||
completionCount: 0,
|
completionCount: 0,
|
||||||
kanbanColumn: nil,
|
kanbanColumn: nil,
|
||||||
completions: [],
|
completions: [],
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ struct SwipeHintView: View {
|
|||||||
isCancelled: false,
|
isCancelled: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
parentTaskId: nil,
|
parentTaskId: nil,
|
||||||
|
templateId: nil,
|
||||||
completionCount: 0,
|
completionCount: 0,
|
||||||
kanbanColumn: nil,
|
kanbanColumn: nil,
|
||||||
completions: [],
|
completions: [],
|
||||||
@@ -182,6 +183,7 @@ struct SwipeHintView: View {
|
|||||||
isCancelled: false,
|
isCancelled: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
parentTaskId: nil,
|
parentTaskId: nil,
|
||||||
|
templateId: nil,
|
||||||
completionCount: 3,
|
completionCount: 3,
|
||||||
kanbanColumn: nil,
|
kanbanColumn: nil,
|
||||||
completions: [],
|
completions: [],
|
||||||
|
|||||||
@@ -495,7 +495,8 @@ struct TaskFormView: View {
|
|||||||
assignedToId: nil,
|
assignedToId: nil,
|
||||||
dueDate: dueDateString,
|
dueDate: dueDateString,
|
||||||
estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0),
|
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
|
viewModel.updateTask(id: task.id, request: request) { success in
|
||||||
@@ -532,7 +533,8 @@ struct TaskFormView: View {
|
|||||||
assignedToId: nil,
|
assignedToId: nil,
|
||||||
dueDate: dueDateString,
|
dueDate: dueDateString,
|
||||||
estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0),
|
estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0),
|
||||||
contractorId: nil
|
contractorId: nil,
|
||||||
|
templateId: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.createTask(request: request) { success in
|
viewModel.createTask(request: request) { success in
|
||||||
|
|||||||
Reference in New Issue
Block a user