Complete re-validation remediation: KMP architecture, iOS platform, XCUITest rewrite
Phases 1-6 of fixes.md — closes all 13 issues from codex_issues_2.md re-validation: KMP Architecture: - Fix subscription purchase/restore response contract (VerificationResponse aligned) - Add feature benefits auth token + APILayer init flow - Remove ResidenceFormScreen direct API bypass (use APILayer) - Wire paywall purchase/restore to real SubscriptionApi calls iOS Platform: - Add iOS Keychain token storage via Swift KeychainHelper - Implement Google Sign-In via ASWebAuthenticationSession (GoogleSignInManager) - DocumentViewModelWrapper observes DataManager for auto-updates - Add missing accessibility identifiers (document, task columns, Google Sign-In) XCUITest Rewrite: - Rewrite test infrastructure: zero sleep() calls, accessibility ID lookups - Create AuthCriticalPathTests and NavigationCriticalPathTests - Delete 14 legacy brittle test files (Suite0-10, templates) - Fix CaseraTests module import (@testable import Casera) All platforms build clean. TEST BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import ComposeApp
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
// MARK: - Architecture Note
|
||||
//
|
||||
@@ -17,7 +18,7 @@ import SwiftUI
|
||||
// - Used by detail views (DocumentDetailView, EditDocumentView)
|
||||
// - Manages explicit state types (Loading/Success/Error) for single-document operations
|
||||
// - Loads individual document detail, handles update and delete with state feedback
|
||||
// - Does NOT observe DataManager -- loads fresh data per-request via APILayer
|
||||
// - Observes DataManager for automatic detail updates after mutations
|
||||
// - Uses protocol-based state enums for SwiftUI view branching
|
||||
//
|
||||
// Both call through APILayer (which updates DataManager), so list views
|
||||
@@ -70,6 +71,7 @@ struct DeleteImageStateError: DeleteImageState {
|
||||
let message: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class DocumentViewModelWrapper: ObservableObject {
|
||||
@Published var documentsState: DocumentState = DocumentStateIdle()
|
||||
@Published var documentDetailState: DocumentDetailState = DocumentDetailStateIdle()
|
||||
@@ -77,6 +79,25 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
@Published var deleteState: DeleteState = DeleteStateIdle()
|
||||
@Published var deleteImageState: DeleteImageState = DeleteImageStateIdle()
|
||||
|
||||
/// The document ID currently loaded in detail view, used for auto-update
|
||||
private var loadedDocumentId: Int32?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init() {
|
||||
// Observe DataManager documents for auto-update of loaded detail
|
||||
DataManagerObservable.shared.$documents
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] documents in
|
||||
guard let self, let docId = self.loadedDocumentId else { return }
|
||||
// Only auto-update if we're in a success state
|
||||
guard self.documentDetailState is DocumentDetailStateSuccess else { return }
|
||||
if let updated = documents.first(where: { $0.id?.int32Value == docId }) {
|
||||
self.documentDetailState = DocumentDetailStateSuccess(document: updated)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func loadDocuments(
|
||||
residenceId: Int32? = nil,
|
||||
documentType: String? = nil,
|
||||
@@ -87,9 +108,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
tags: String? = nil,
|
||||
search: String? = nil
|
||||
) {
|
||||
DispatchQueue.main.async {
|
||||
self.documentsState = DocumentStateLoading()
|
||||
}
|
||||
self.documentsState = DocumentStateLoading()
|
||||
|
||||
Task {
|
||||
do {
|
||||
@@ -105,7 +124,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
forceRefresh: false
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
do {
|
||||
if let success = result as? ApiResultSuccess<NSArray> {
|
||||
let documents = success.data as? [Document] ?? []
|
||||
self.documentsState = DocumentStateSuccess(documents: documents)
|
||||
@@ -116,7 +135,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
do {
|
||||
self.documentsState = DocumentStateError(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
@@ -124,15 +143,14 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
|
||||
func loadDocumentDetail(id: Int32) {
|
||||
DispatchQueue.main.async {
|
||||
self.documentDetailState = DocumentDetailStateLoading()
|
||||
}
|
||||
loadedDocumentId = id
|
||||
self.documentDetailState = DocumentDetailStateLoading()
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await APILayer.shared.getDocument(id: id, forceRefresh: false)
|
||||
|
||||
await MainActor.run {
|
||||
do {
|
||||
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
||||
self.documentDetailState = DocumentDetailStateSuccess(document: document)
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
@@ -142,7 +160,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
do {
|
||||
self.documentDetailState = DocumentDetailStateError(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
@@ -170,9 +188,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
startDate: String? = nil,
|
||||
endDate: String? = nil
|
||||
) {
|
||||
DispatchQueue.main.async {
|
||||
self.updateState = UpdateStateLoading()
|
||||
}
|
||||
self.updateState = UpdateStateLoading()
|
||||
|
||||
Task {
|
||||
do {
|
||||
@@ -199,7 +215,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
endDate: endDate
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
do {
|
||||
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
||||
self.updateState = UpdateStateSuccess(document: document)
|
||||
// Also refresh the detail state
|
||||
@@ -211,7 +227,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
do {
|
||||
self.updateState = UpdateStateError(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
@@ -219,15 +235,13 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
|
||||
func deleteDocument(id: Int32) {
|
||||
DispatchQueue.main.async {
|
||||
self.deleteState = DeleteStateLoading()
|
||||
}
|
||||
self.deleteState = DeleteStateLoading()
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await APILayer.shared.deleteDocument(id: id)
|
||||
|
||||
await MainActor.run {
|
||||
do {
|
||||
if result is ApiResultSuccess<KotlinUnit> {
|
||||
self.deleteState = DeleteStateSuccess()
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
@@ -237,7 +251,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
do {
|
||||
self.deleteState = DeleteStateError(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
@@ -245,27 +259,21 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
|
||||
func resetUpdateState() {
|
||||
DispatchQueue.main.async {
|
||||
self.updateState = UpdateStateIdle()
|
||||
}
|
||||
self.updateState = UpdateStateIdle()
|
||||
}
|
||||
|
||||
func resetDeleteState() {
|
||||
DispatchQueue.main.async {
|
||||
self.deleteState = DeleteStateIdle()
|
||||
}
|
||||
self.deleteState = DeleteStateIdle()
|
||||
}
|
||||
|
||||
func deleteDocumentImage(documentId: Int32, imageId: Int32) {
|
||||
DispatchQueue.main.async {
|
||||
self.deleteImageState = DeleteImageStateLoading()
|
||||
}
|
||||
self.deleteImageState = DeleteImageStateLoading()
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await APILayer.shared.deleteDocumentImage(documentId: documentId, imageId: imageId)
|
||||
|
||||
await MainActor.run {
|
||||
do {
|
||||
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
||||
self.deleteImageState = DeleteImageStateSuccess()
|
||||
// Refresh detail state with updated document (image removed)
|
||||
@@ -277,7 +285,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
do {
|
||||
self.deleteImageState = DeleteImageStateError(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
@@ -285,8 +293,6 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
|
||||
func resetDeleteImageState() {
|
||||
DispatchQueue.main.async {
|
||||
self.deleteImageState = DeleteImageStateIdle()
|
||||
}
|
||||
self.deleteImageState = DeleteImageStateIdle()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user