Applies verified fixes from deep audit (concurrency, performance, security, accessibility), standardizes CRUD form buttons to Add/Save pattern, removes .drawingGroup() that broke search bar TextFields, and converts vulnerable .sheet(isPresented:) + if-let patterns to safe presentation to prevent blank white modals. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
362 lines
14 KiB
Swift
362 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) {
|
|
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) {
|
|
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: false)
|
|
|
|
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,
|
|
createdAt: now,
|
|
updatedAt: now
|
|
)
|
|
}
|
|
}
|