Files
honeyDueKMP/iosApp/iosApp/Residence/ResidenceViewModel.swift
T
Trey T db65db6232
Android UI Tests / ui-tests (push) Has been cancelled
i18n: complete app-wide localization (10 languages) + audit tooling
Localize all user-facing strings across iOS (SwiftUI), shared Kotlin, and
Android Compose into en/es/fr/de/pt/it/ja/ko/nl/zh:
- iOS String Catalogs: main + widget Localizable.xcstrings, InfoPlist.xcstrings
  (permissions), plural variations, ~200 new keys translated
- Shared Kotlin ClientStrings table + Android composeResources/values-* (884 keys
  ×10), routed Api/ViewModel/util error & UI strings through localization
- Backend-localized lookups/suggestions consumed via display names
- Widget extension catalog; theme names, home-profile fallbacks, validation,
  network errors, accessibility labels all localized

Add re-runnable verification gates:
- scripts/i18n_audit.py  — enumerate every literal, partition to GAP=0
- scripts/i18n_coverage.py — all 10 locales translated, format-specifier parity

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:52:28 -05:00

416 lines
16 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>()
private let dataManager: DataManagerObservable
// MARK: - Initialization
/// - Parameters:
/// - dataManager: Observable cache the VM subscribes to. Defaults
/// to the shared singleton. Tests and the parity-gallery pass a
/// fixture-backed instance instead.
/// - initialSelectedResidenceId: If set, the VM synchronously
/// resolves `selectedResidence` from the current `residences`
/// cache on init. Used by snapshot tests/previews so
/// `ResidenceDetailView` renders populated state on the first
/// composition frame, bypassing `getResidence(id:)`'s APILayer
/// round-trip which fails hermetically.
init(
dataManager: DataManagerObservable = .shared,
initialSelectedResidenceId: Int32? = nil
) {
self.dataManager = dataManager
// Seed the VM's @Published mirrors synchronously from the current
// cache values so snapshot tests and previews render populated
// state without waiting for Combine's async dispatch. Production
// runs hit this path too but the values are identical to what
// the `.sink` closure would assign moments later on the main queue.
self.myResidences = dataManager.myResidences
self.residences = dataManager.residences
self.totalSummary = dataManager.totalSummary
if let id = initialSelectedResidenceId {
// Try myResidences first (full response), then the top-level list.
self.selectedResidence = dataManager.myResidences?.residences
.first(where: { $0.id == id })
?? dataManager.residences.first(where: { $0.id == id })
}
// Observe injected DataManagerObservable for residence data
dataManager.$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)
dataManager.$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)
dataManager.$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 !dataManager.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")] // i18n-ignore: UI-test mock data (non-UI)
}
}
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 && dataManager.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 ? String(localized: "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 = String(localized: "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 = String(localized: "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 ?? String(localized: "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 = String(localized: "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 = String(localized: "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" // i18n-ignore: ISO timestamp for UI-test mock data (non-UI)
return ResidenceResponse(
id: Int32(id),
ownerId: 1,
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@example.com", firstName: "UI", lastName: "Tester"), // i18n-ignore: UI-test mock data (non-UI)
users: [],
name: name,
propertyTypeId: 1,
propertyType: ResidenceType(id: 1, name: "House", displayNameLocalized: ""), // i18n-ignore: UI-test mock model value (non-UI)
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,
heatingType: nil,
coolingType: nil,
waterHeaterType: nil,
roofType: nil,
hasPool: false,
hasSprinklerSystem: false,
hasSeptic: false,
hasFireplace: false,
hasGarage: false,
hasBasement: false,
hasAttic: false,
exteriorType: nil,
flooringPrimary: nil,
landscapingType: nil,
createdAt: now,
updatedAt: now
)
}
}