Files
honeyDueKMP/iosApp/iosApp/Profile/ProfileViewModel.swift
Trey T 6c3c9d3e0c Coverage: iOS ViewModel DI seam + populated-state snapshots
6 user-facing ViewModels now accept optional `dataManager: DataManagerObservable = .shared`
init param — production call-sites unchanged; tests inject fixture-backed
observables. Refactored: ResidenceViewModel, TaskViewModel, ContractorViewModel,
DocumentViewModel, ProfileViewModel, LoginViewModel.

DataManagerObservable gains test-only init(observeSharedDataManager:) + convenience
init(kotlin: IDataManager).

SnapshotGalleryTests.setUp() resets .shared to FixtureDataManager.empty() per test;
populated tests call seedPopulated() to copy every StateFlow from
FixtureDataManager.populated() onto .shared synchronously. 15 populated surfaces ×
2 modes = 30 new PNGs.

iOS goldens: 58 → 88. 44 SnapshotGalleryTests green.

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

151 lines
5.2 KiB
Swift

import Foundation
import ComposeApp
import Combine
/// ViewModel for user profile management.
/// Observes DataManagerObservable for current user.
/// Calls APILayer directly for profile updates.
@MainActor
class ProfileViewModel: ObservableObject {
// MARK: - Published Properties
@Published var firstName: String = ""
@Published var lastName: String = ""
@Published var email: String = ""
@Published var isLoading: Bool = false
@Published var isLoadingUser: Bool = true
@Published var errorMessage: String?
@Published var successMessage: String?
// MARK: - Private Properties
private let tokenStorage: TokenStorageProtocol
private let dataManager: DataManagerObservable
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
/// - Parameter dataManager: Observable cache the VM subscribes to.
/// Defaults to the shared singleton.
init(
tokenStorage: TokenStorageProtocol? = nil,
dataManager: DataManagerObservable = .shared
) {
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
self.dataManager = dataManager
// Seed from current cache so snapshot tests/previews render
// populated state without waiting for Combine's async dispatch.
if let user = dataManager.currentUser {
self.firstName = user.firstName ?? ""
self.lastName = user.lastName ?? ""
self.email = user.email
self.isLoadingUser = false
}
// Observe current user from injected DataManagerObservable
dataManager.$currentUser
.receive(on: DispatchQueue.main)
.sink { [weak self] user in
guard let self else { return }
if let user = user {
self.firstName = user.firstName ?? ""
self.lastName = user.lastName ?? ""
self.email = user.email
self.isLoadingUser = false
}
}
.store(in: &cancellables)
// Load current user data
loadCurrentUser()
}
// MARK: - Public Methods
func loadCurrentUser() {
guard tokenStorage.getToken() != nil else {
errorMessage = "Not authenticated"
isLoadingUser = false
return
}
// Check if we already have user data
if dataManager.currentUser != nil {
isLoadingUser = false
return
}
isLoadingUser = true
errorMessage = nil
Task {
do {
let result = try await APILayer.shared.getCurrentUser(forceRefresh: false)
// DataManager is updated by APILayer, UI updates via Combine observation
if result is ApiResultSuccess<User> {
self.isLoadingUser = false
self.errorMessage = nil
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoadingUser = false
} else {
self.errorMessage = "Failed to load profile"
self.isLoadingUser = false
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoadingUser = false
}
}
}
func updateProfile() {
guard !email.isEmpty else {
errorMessage = "Email is required"
return
}
guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated"
return
}
isLoading = true
errorMessage = nil
successMessage = nil
Task {
do {
let request = UpdateProfileRequest(
firstName: firstName.isEmpty ? nil : firstName,
lastName: lastName.isEmpty ? nil : lastName,
email: email
)
let result = try await APILayer.shared.updateProfile(token: token, request: request)
// DataManager is updated by APILayer, UI updates via Combine observation
if result is ApiResultSuccess<User> {
self.isLoading = false
self.errorMessage = nil
self.successMessage = "Profile updated successfully"
} else if let error = ApiResultBridge.error(from: result) {
self.isLoading = false
self.errorMessage = ErrorMessageParser.parse(error.message)
self.successMessage = nil
} else {
self.isLoading = false
self.errorMessage = "Failed to update profile"
self.successMessage = nil
}
} catch {
self.isLoading = false
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.successMessage = nil
}
}
}
func clearMessages() {
errorMessage = nil
successMessage = nil
}
}