db65db6232
Android UI Tests / ui-tests (push) Has been cancelled
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>
416 lines
16 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|