Files
honeyDueKMP/iosApp/iosApp/Residence/ResidenceViewModel.swift
Trey T 8f86fa2cd0 Fix 12 iOS issues: race conditions, data flow, UX
Critical bugs:
- RootView: auth check deferred to .task{} modifier (after DataManager init)
- DataManagerObservable: map conversion failures now logged with key details
- ContractorViewModel: replace stuck boolean flag with time-based suppression
- DocumentViewModel: guard full success.data before image upload

Logic fixes:
- AllTasksView: 300ms delay before animation flag release
- ResidenceViewModel: trigger initializeLookups() if not ready
- TaskFormView: hasDueDate toggle prevents defaulting to today
- OnboardingState: guard isAuthenticated before completing onboarding

UX fixes:
- ResidencesListView: 10-second refresh timeout
- AllTasksView: add button disabled while sheet presented
- TaskViewModel: actionState auto-resets after 3s, explicit reset on consume
2026-03-26 18:01:49 -05:00

370 lines
14 KiB
Swift

import Foundation
import ComposeApp
import Combine
/// ViewModel for residence management.
/// Observes DataManagerObservable for cached data.
/// Kicks off API calls that update DataManager, letting views react to cache updates.
@MainActor
class ResidenceViewModel: ObservableObject {
private static var uiTestMockResidences: [ResidenceResponse] = []
private static var uiTestNextResidenceId: Int = 1000
// MARK: - Published Properties (from DataManager observation)
@Published var myResidences: MyResidencesResponse?
@Published var residences: [ResidenceResponse] = []
@Published var totalSummary: TotalSummary?
// MARK: - Local State
@Published var selectedResidence: ResidenceResponse?
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@Published var isGeneratingReport: Bool = false
@Published var reportMessage: String?
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init() {
// Observe DataManagerObservable for residence data
DataManagerObservable.shared.$myResidences
.receive(on: DispatchQueue.main)
.sink { [weak self] myResidences in
self?.myResidences = myResidences
// Clear loading state when data arrives
if myResidences != nil {
self?.isLoading = false
}
// Auto-update selectedResidence if it exists in the updated list
if let currentSelected = self?.selectedResidence,
let updatedResidence = myResidences?.residences.first(where: { $0.id == currentSelected.id }) {
self?.selectedResidence = updatedResidence
}
}
.store(in: &cancellables)
DataManagerObservable.shared.$residences
.receive(on: DispatchQueue.main)
.sink { [weak self] residences in
self?.residences = residences
// Auto-update selectedResidence if it exists in the updated list
if let currentSelected = self?.selectedResidence,
let updatedResidence = residences.first(where: { $0.id == currentSelected.id }) {
self?.selectedResidence = updatedResidence
}
}
.store(in: &cancellables)
DataManagerObservable.shared.$totalSummary
.receive(on: DispatchQueue.main)
.sink { [weak self] summary in
self?.totalSummary = summary
}
.store(in: &cancellables)
}
// MARK: - Public Methods
/// Load summary - kicks off API call that updates DataManager
func loadSummary(forceRefresh: Bool = false) {
errorMessage = nil
// Check if we have cached data and don't need to refresh
if !forceRefresh && totalSummary != nil {
return
}
isLoading = true
// Kick off API call - DataManager will be updated, which updates DataManagerObservable
Task {
do {
let result = try await APILayer.shared.getSummary(forceRefresh: forceRefresh)
// Only handle errors - success updates DataManager automatically
if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
}
self.isLoading = false
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
}
}
}
/// Load my residences - checks cache first, then fetches if needed
func loadMyResidences(forceRefresh: Bool = false) {
// Ensure lookups are initialized (may not be during onboarding)
if !DataManagerObservable.shared.lookupsInitialized {
Task {
_ = try? await APILayer.shared.initializeLookups()
}
}
if UITestRuntime.shouldMockAuth {
if Self.uiTestMockResidences.isEmpty || forceRefresh {
if Self.uiTestMockResidences.isEmpty {
Self.uiTestMockResidences = [makeMockResidence(name: "Seed Residence")]
}
}
myResidences = MyResidencesResponse(residences: Self.uiTestMockResidences)
isLoading = false
errorMessage = nil
return
}
errorMessage = nil
// Check if we have cached data and don't need to refresh
if !forceRefresh && DataManagerObservable.shared.myResidences != nil {
// Data already available via observation, no API call needed
return
}
isLoading = true
// Kick off API call - DataManager will be updated, which updates DataManagerObservable,
// which updates our @Published myResidences via the sink above
Task {
do {
let result = try await APILayer.shared.getMyResidences(forceRefresh: forceRefresh)
// Only handle errors - success updates DataManager automatically
if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
}
}
}
func getResidence(id: Int32, forceRefresh: Bool = false) {
if UITestRuntime.shouldMockAuth {
selectedResidence = Self.uiTestMockResidences.first(where: { $0.id == id })
isLoading = false
errorMessage = selectedResidence == nil ? "Residence not found" : nil
return
}
isLoading = true
errorMessage = nil
Task {
do {
let result = try await APILayer.shared.getResidence(id: id, forceRefresh: forceRefresh)
if let success = result as? ApiResultSuccess<ResidenceResponse> {
self.selectedResidence = success.data
self.isLoading = false
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
} else {
self.errorMessage = "Failed to load residence"
self.isLoading = false
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
}
}
}
func createResidence(request: ResidenceCreateRequest, completion: @escaping (Bool) -> Void) {
createResidence(request: request) { result in
completion(result != nil)
}
}
/// Creates a residence and returns the created residence on success
func createResidence(request: ResidenceCreateRequest, completion: @escaping (ResidenceResponse?) -> Void) {
if UITestRuntime.shouldMockAuth {
let residence = makeMockResidence(
name: request.name,
streetAddress: request.streetAddress ?? "",
city: request.city ?? "",
stateProvince: request.stateProvince ?? "",
postalCode: request.postalCode ?? ""
)
Self.uiTestMockResidences.append(residence)
myResidences = MyResidencesResponse(residences: Self.uiTestMockResidences)
isLoading = false
errorMessage = nil
completion(residence)
return
}
isLoading = true
errorMessage = nil
Task {
do {
let result = try await APILayer.shared.createResidence(request: request)
await MainActor.run {
if let success = result as? ApiResultSuccess<ResidenceResponse> {
if let residence = success.data {
self.isLoading = false
completion(residence)
} else {
self.isLoading = false
completion(nil)
}
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
completion(nil)
} else {
self.isLoading = false
completion(nil)
}
}
} catch {
await MainActor.run {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
completion(nil)
}
}
}
}
func updateResidence(id: Int32, request: ResidenceCreateRequest, completion: @escaping (Bool) -> Void) {
isLoading = true
errorMessage = nil
Task {
do {
let result = try await APILayer.shared.updateResidence(id: id, request: request)
if let success = result as? ApiResultSuccess<ResidenceResponse> {
self.selectedResidence = success.data
self.isLoading = false
// DataManager is updated by APILayer (including refreshMyResidences),
// which updates DataManagerObservable, which updates our @Published
// myResidences via Combine subscription
completion(true)
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
completion(false)
} else {
self.errorMessage = "Failed to update residence"
self.isLoading = false
completion(false)
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
completion(false)
}
}
}
func generateTasksReport(residenceId: Int32, email: String? = nil) {
isGeneratingReport = true
reportMessage = nil
Task {
do {
let result = try await APILayer.shared.generateTasksReport(residenceId: residenceId, email: email)
if let success = result as? ApiResultSuccess<GenerateReportResponse> {
self.reportMessage = success.data?.message ?? "Report generated, but no message returned."
self.isGeneratingReport = false
} else if let error = ApiResultBridge.error(from: result) {
self.reportMessage = ErrorMessageParser.parse(error.message)
self.isGeneratingReport = false
} else {
self.reportMessage = "Failed to generate report"
self.isGeneratingReport = false
}
} catch {
self.reportMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isGeneratingReport = false
}
}
}
func clearError() {
errorMessage = nil
}
func joinWithCode(code: String, completion: @escaping (Bool) -> Void) {
isLoading = true
errorMessage = nil
Task {
do {
let result = try await APILayer.shared.joinWithCode(code: code)
if result is ApiResultSuccess<JoinResidenceResponse> {
self.isLoading = false
// APILayer updates DataManager with refreshMyResidences,
// which updates DataManagerObservable, which updates our
// @Published myResidences via Combine subscription
completion(true)
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
completion(false)
} else {
self.errorMessage = "Failed to join residence"
self.isLoading = false
completion(false)
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
completion(false)
}
}
}
private func makeMockResidence(
name: String,
streetAddress: String = "",
city: String = "",
stateProvince: String = "",
postalCode: String = ""
) -> ResidenceResponse {
let id = Self.uiTestNextResidenceId
Self.uiTestNextResidenceId += 1
let now = "2026-02-20T00:00:00Z"
return ResidenceResponse(
id: Int32(id),
ownerId: 1,
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@example.com", firstName: "UI", lastName: "Tester"),
users: [],
name: name,
propertyTypeId: 1,
propertyType: ResidenceType(id: 1, name: "House"),
streetAddress: streetAddress,
apartmentUnit: "",
city: city,
stateProvince: stateProvince,
postalCode: postalCode,
country: "USA",
bedrooms: nil,
bathrooms: nil,
squareFootage: nil,
lotSize: nil,
yearBuilt: nil,
description: "",
purchaseDate: nil,
purchasePrice: nil,
isPrimary: false,
isActive: true,
overdueCount: 0,
completionSummary: nil,
createdAt: now,
updatedAt: now
)
}
}