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,9 +42,7 @@ extension DataLayerTests {
|
||||
iconAndroid: "",
|
||||
tags: tags,
|
||||
displayOrder: 0,
|
||||
isActive: true,
|
||||
regionId: nil,
|
||||
regionName: nil
|
||||
isActive: true
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
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,
|
||||
isArchived: false,
|
||||
parentTaskId: nil,
|
||||
templateId: nil,
|
||||
completionCount: 0,
|
||||
kanbanColumn: nil,
|
||||
completions: [],
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user