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 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:

View File

@@ -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"

View File

@@ -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
) )
/** /**

View File

@@ -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

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> { 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.
*/ */

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>> { 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/") {

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. * Get personalized task suggestions for a residence based on its home profile.
* Requires authentication. * Requires authentication.

View File

@@ -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

View File

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

View File

@@ -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

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, isCancelled: false,
isArchived: false, isArchived: false,
parentTaskId: nil, parentTaskId: nil,
templateId: nil,
completionCount: 0, completionCount: 0,
kanbanColumn: nil, kanbanColumn: nil,
completions: [], completions: [],

View File

@@ -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: [],

View File

@@ -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