Files
honeyDueKMP/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift
Trey t 98dbacdea0 Add task completion animations, subscription trials, and quiet debug console
- Completion animations: play user-selected animation on task card after completing,
  with DataManager guard to prevent race condition during animation playback.
  Works in both AllTasksView and ResidenceDetailView. Animation preference
  persisted via @AppStorage and configurable from Settings.
- Subscription: add trial fields (trialStart, trialEnd, trialActive) and
  subscriptionSource to model, cross-platform purchase guard, trial banner
  in upgrade prompt, and platform-aware subscription management in profile.
- Analytics: disable PostHog SDK debug logging and remove console print
  statements to reduce debug console noise.
- Documents: remove redundant nested do-catch blocks in ViewModel wrapper.
- Widgets: add debounced timeline reloads and thread-safe file I/O queue.
- Onboarding: fix animation leak on disappear, remove unused state vars.
- Remove unused files (ContentView, StateFlowExtensions, CustomView).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:35:08 -06:00

279 lines
10 KiB
Swift

import Foundation
import ComposeApp
import SwiftUI
import Combine
// MARK: - Architecture Note
//
// Two document ViewModels coexist with distinct responsibilities:
//
// DocumentViewModel (DocumentViewModel.swift):
// - Used by list views (DocumentsView, DocumentListView)
// - Observes DataManager via DataManagerObservable for reactive list updates
// - Handles CRUD operations that update DataManager cache (create, update, delete)
// - Supports image upload workflows
// - Uses @MainActor for thread safety
//
// DocumentViewModelWrapper (this file):
// - 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
// - 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
// auto-refresh when detail views perform mutations.
// State wrappers for SwiftUI
protocol DocumentState {}
struct DocumentStateIdle: DocumentState {}
struct DocumentStateLoading: DocumentState {}
struct DocumentStateSuccess: DocumentState {
let documents: [Document]
}
struct DocumentStateError: DocumentState {
let message: String
}
protocol DocumentDetailState {}
struct DocumentDetailStateIdle: DocumentDetailState {}
struct DocumentDetailStateLoading: DocumentDetailState {}
struct DocumentDetailStateSuccess: DocumentDetailState {
let document: Document
}
struct DocumentDetailStateError: DocumentDetailState {
let message: String
}
protocol UpdateState {}
struct UpdateStateIdle: UpdateState {}
struct UpdateStateLoading: UpdateState {}
struct UpdateStateSuccess: UpdateState {
let document: Document
}
struct UpdateStateError: UpdateState {
let message: String
}
protocol DeleteState {}
struct DeleteStateIdle: DeleteState {}
struct DeleteStateLoading: DeleteState {}
struct DeleteStateSuccess: DeleteState {}
struct DeleteStateError: DeleteState {
let message: String
}
protocol DeleteImageState {}
struct DeleteImageStateIdle: DeleteImageState {}
struct DeleteImageStateLoading: DeleteImageState {}
struct DeleteImageStateSuccess: DeleteImageState {}
struct DeleteImageStateError: DeleteImageState {
let message: String
}
@MainActor
class DocumentViewModelWrapper: ObservableObject {
@Published var documentsState: DocumentState = DocumentStateIdle()
@Published var documentDetailState: DocumentDetailState = DocumentDetailStateIdle()
@Published var updateState: UpdateState = UpdateStateIdle()
@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,
category: String? = nil,
contractorId: Int32? = nil,
isActive: Bool? = nil,
expiringSoon: Int32? = nil,
tags: String? = nil,
search: String? = nil
) {
self.documentsState = DocumentStateLoading()
Task {
do {
let result = try await APILayer.shared.getDocuments(
residenceId: residenceId.asKotlin,
documentType: documentType,
category: category,
contractorId: contractorId.asKotlin,
isActive: isActive.asKotlin,
expiringSoon: expiringSoon.asKotlin,
tags: tags,
search: search,
forceRefresh: false
)
if let success = result as? ApiResultSuccess<NSArray> {
let documents = success.data as? [Document] ?? []
self.documentsState = DocumentStateSuccess(documents: documents)
} else if let error = ApiResultBridge.error(from: result) {
self.documentsState = DocumentStateError(message: error.message)
} else {
self.documentsState = DocumentStateError(message: "Failed to load documents")
}
} catch {
self.documentsState = DocumentStateError(message: error.localizedDescription)
}
}
}
func loadDocumentDetail(id: Int32) {
loadedDocumentId = id
self.documentDetailState = DocumentDetailStateLoading()
Task {
do {
let result = try await APILayer.shared.getDocument(id: id, forceRefresh: false)
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
self.documentDetailState = DocumentDetailStateSuccess(document: document)
} else if let error = ApiResultBridge.error(from: result) {
self.documentDetailState = DocumentDetailStateError(message: error.message)
} else {
self.documentDetailState = DocumentDetailStateError(message: "Failed to load document")
}
} catch {
self.documentDetailState = DocumentDetailStateError(message: error.localizedDescription)
}
}
}
func updateDocument(
id: Int32,
title: String,
documentType: String,
description: String? = nil,
category: String? = nil,
tags: String? = nil,
notes: String? = nil,
isActive: Bool = true,
itemName: String? = nil,
modelNumber: String? = nil,
serialNumber: String? = nil,
provider: String? = nil,
providerContact: String? = nil,
claimPhone: String? = nil,
claimEmail: String? = nil,
claimWebsite: String? = nil,
purchaseDate: String? = nil,
startDate: String? = nil,
endDate: String? = nil
) {
self.updateState = UpdateStateLoading()
Task {
do {
let result = try await APILayer.shared.updateDocument(
id: id,
title: title,
documentType: documentType,
description: description,
category: category,
tags: tags,
notes: notes,
contractorId: nil,
isActive: isActive,
itemName: itemName,
modelNumber: modelNumber,
serialNumber: serialNumber,
provider: provider,
providerContact: providerContact,
claimPhone: claimPhone,
claimEmail: claimEmail,
claimWebsite: claimWebsite,
purchaseDate: purchaseDate,
startDate: startDate,
endDate: endDate
)
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
self.updateState = UpdateStateSuccess(document: document)
// Also refresh the detail state
self.documentDetailState = DocumentDetailStateSuccess(document: document)
} else if let error = ApiResultBridge.error(from: result) {
self.updateState = UpdateStateError(message: error.message)
} else {
self.updateState = UpdateStateError(message: "Failed to update document")
}
} catch {
self.updateState = UpdateStateError(message: error.localizedDescription)
}
}
}
func deleteDocument(id: Int32) {
self.deleteState = DeleteStateLoading()
Task {
do {
let result = try await APILayer.shared.deleteDocument(id: id)
if result is ApiResultSuccess<KotlinUnit> {
self.deleteState = DeleteStateSuccess()
} else if let error = ApiResultBridge.error(from: result) {
self.deleteState = DeleteStateError(message: error.message)
} else {
self.deleteState = DeleteStateError(message: "Failed to delete document")
}
} catch {
self.deleteState = DeleteStateError(message: error.localizedDescription)
}
}
}
func resetUpdateState() {
self.updateState = UpdateStateIdle()
}
func resetDeleteState() {
self.deleteState = DeleteStateIdle()
}
func deleteDocumentImage(documentId: Int32, imageId: Int32) {
self.deleteImageState = DeleteImageStateLoading()
Task {
do {
let result = try await APILayer.shared.deleteDocumentImage(documentId: documentId, imageId: imageId)
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
self.deleteImageState = DeleteImageStateSuccess()
// Refresh detail state with updated document (image removed)
self.documentDetailState = DocumentDetailStateSuccess(document: document)
} else if let error = ApiResultBridge.error(from: result) {
self.deleteImageState = DeleteImageStateError(message: error.message)
} else {
self.deleteImageState = DeleteImageStateError(message: "Failed to delete image")
}
} catch {
self.deleteImageState = DeleteImageStateError(message: error.localizedDescription)
}
}
}
func resetDeleteImageState() {
self.deleteImageState = DeleteImageStateIdle()
}
}