Files
honeyDueKMP/iosApp/iosApp/Onboarding/OnboardingTasksViewModel.swift
Trey T 42ccbdcbd6 P2: iOS Full DI — all 11 VMs accept dataManager init param
Adds the DI seam to the 5 previously singleton-coupled VMs:
- VerifyEmailViewModel
- RegisterViewModel
- PasswordResetViewModel
- AppleSignInViewModel
- OnboardingTasksViewModel

All now accept init(dataManager: DataManagerObservable = .shared).

iOSApp.swift injects DataManagerObservable.shared at the root via
.environmentObject so descendant views can reach it via @EnvironmentObject
without implicit singleton reads.

Dependencies.swift factories updated to pass DataManager.shared explicitly
into Kotlin VM constructors — SKIE doesn't surface Kotlin default init
parameters as Swift defaults, so every Kotlin VM call-site needs the
explicit argument. Affects makeAuthViewModel, makeResidenceViewModel,
makeTaskViewModel, makeContractorViewModel, makeDocumentViewModel.

Full iOS build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:47:58 -05:00

132 lines
5.1 KiB
Swift

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: - Private Properties
private let dataManager: DataManagerObservable
// MARK: - Initialization
init(dataManager: DataManagerObservable = .shared) {
self.dataManager = dataManager
}
// 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
}
}
}