- 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>
279 lines
10 KiB
Swift
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()
|
|
}
|
|
}
|