Add document viewing, editing, and image deletion features
- Add DocumentDetailScreen and EditDocumentScreen for Compose (Android/Web) - Add DocumentDetailView and EditDocumentView for iOS SwiftUI - Add DocumentViewModelWrapper for iOS state management - Implement document image deletion API integration - Fix iOS navigation issues with edit button using hidden NavigationLink - Add clickable warranties in iOS with NavigationLink - Fix iOS build errors with proper type checking and state handling - Add support for viewing and managing warranty-specific fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
528
iosApp/iosApp/Documents/DocumentDetailView.swift
Normal file
528
iosApp/iosApp/Documents/DocumentDetailView.swift
Normal file
@@ -0,0 +1,528 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
struct DocumentDetailView: View {
|
||||
let documentId: Int32
|
||||
@StateObject private var viewModel = DocumentViewModelWrapper()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showDeleteAlert = false
|
||||
@State private var navigateToEdit = false
|
||||
@State private var showImageViewer = false
|
||||
@State private var selectedImageIndex = 0
|
||||
@State private var deleteSucceeded = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if viewModel.documentDetailState is DocumentDetailStateLoading {
|
||||
ProgressView("Loading document...")
|
||||
} else if let successState = viewModel.documentDetailState as? DocumentDetailStateSuccess {
|
||||
documentDetailContent(document: successState.document)
|
||||
} else if let errorState = viewModel.documentDetailState as? DocumentDetailStateError {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.red)
|
||||
Text(errorState.message)
|
||||
.foregroundColor(.secondary)
|
||||
Button("Retry") {
|
||||
viewModel.loadDocumentDetail(id: documentId)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Document Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(
|
||||
// Hidden NavigationLink for programmatic navigation to edit
|
||||
Group {
|
||||
if let successState = viewModel.documentDetailState as? DocumentDetailStateSuccess {
|
||||
NavigationLink(
|
||||
destination: EditDocumentView(document: successState.document),
|
||||
isActive: $navigateToEdit
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if viewModel.documentDetailState is DocumentDetailStateSuccess {
|
||||
Menu {
|
||||
Button {
|
||||
navigateToEdit = true
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
showDeleteAlert = true
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.loadDocumentDetail(id: documentId)
|
||||
}
|
||||
.alert("Delete Document", isPresented: $showDeleteAlert) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Delete", role: .destructive) {
|
||||
viewModel.deleteDocument(id: documentId)
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete this document? This action cannot be undone.")
|
||||
}
|
||||
.onReceive(viewModel.$deleteState) { newState in
|
||||
if newState is DeleteStateSuccess {
|
||||
deleteSucceeded = true
|
||||
}
|
||||
}
|
||||
.onChange(of: deleteSucceeded) { succeeded in
|
||||
if succeeded {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showImageViewer) {
|
||||
if let successState = viewModel.documentDetailState as? DocumentDetailStateSuccess, !successState.document.images.isEmpty {
|
||||
ImageViewerSheet(
|
||||
images: successState.document.images,
|
||||
selectedIndex: $selectedImageIndex,
|
||||
onDismiss: { showImageViewer = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func documentDetailContent(document: Document) -> some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Status Badge (for warranties)
|
||||
if document.documentType == "warranty" {
|
||||
warrantyStatusCard(document: document)
|
||||
}
|
||||
|
||||
// Basic Information
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Basic Information")
|
||||
|
||||
detailRow(label: "Title", value: document.title)
|
||||
detailRow(label: "Type", value: DocumentTypeHelper.displayName(for: document.documentType))
|
||||
|
||||
if let category = document.category {
|
||||
detailRow(label: "Category", value: DocumentCategoryHelper.displayName(for: category))
|
||||
}
|
||||
|
||||
if let description = document.description_, !description.isEmpty {
|
||||
detailRow(label: "Description", value: description)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(12)
|
||||
.shadow(radius: 2)
|
||||
|
||||
// Warranty/Item Details
|
||||
if document.documentType == "warranty" {
|
||||
if document.itemName != nil || document.modelNumber != nil ||
|
||||
document.serialNumber != nil || document.provider != nil {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Item Details")
|
||||
|
||||
if let itemName = document.itemName {
|
||||
detailRow(label: "Item Name", value: itemName)
|
||||
}
|
||||
if let modelNumber = document.modelNumber {
|
||||
detailRow(label: "Model Number", value: modelNumber)
|
||||
}
|
||||
if let serialNumber = document.serialNumber {
|
||||
detailRow(label: "Serial Number", value: serialNumber)
|
||||
}
|
||||
if let provider = document.provider {
|
||||
detailRow(label: "Provider", value: provider)
|
||||
}
|
||||
if let providerContact = document.providerContact {
|
||||
detailRow(label: "Provider Contact", value: providerContact)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(12)
|
||||
.shadow(radius: 2)
|
||||
}
|
||||
|
||||
// Claim Information
|
||||
if document.claimPhone != nil || document.claimEmail != nil ||
|
||||
document.claimWebsite != nil {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Claim Information")
|
||||
|
||||
if let claimPhone = document.claimPhone {
|
||||
detailRow(label: "Claim Phone", value: claimPhone)
|
||||
}
|
||||
if let claimEmail = document.claimEmail {
|
||||
detailRow(label: "Claim Email", value: claimEmail)
|
||||
}
|
||||
if let claimWebsite = document.claimWebsite {
|
||||
detailRow(label: "Claim Website", value: claimWebsite)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(12)
|
||||
.shadow(radius: 2)
|
||||
}
|
||||
|
||||
// Dates
|
||||
if document.purchaseDate != nil || document.startDate != nil ||
|
||||
document.endDate != nil {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Important Dates")
|
||||
|
||||
if let purchaseDate = document.purchaseDate {
|
||||
detailRow(label: "Purchase Date", value: purchaseDate)
|
||||
}
|
||||
if let startDate = document.startDate {
|
||||
detailRow(label: "Start Date", value: startDate)
|
||||
}
|
||||
if let endDate = document.endDate {
|
||||
detailRow(label: "End Date", value: endDate)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(12)
|
||||
.shadow(radius: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// Images
|
||||
if !document.images.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Images (\(document.images.count))")
|
||||
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
||||
ForEach(Array(document.images.prefix(6).enumerated()), id: \.element.id) { index, image in
|
||||
ZStack(alignment: .center) {
|
||||
AsyncImage(url: URL(string: image.imageUrl)) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
case .failure:
|
||||
Image(systemName: "photo")
|
||||
.foregroundColor(.gray)
|
||||
case .empty:
|
||||
ProgressView()
|
||||
@unknown default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.frame(height: 100)
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
.onTapGesture {
|
||||
selectedImageIndex = index
|
||||
showImageViewer = true
|
||||
}
|
||||
|
||||
if index == 5 && document.images.count > 6 {
|
||||
Rectangle()
|
||||
.fill(Color.black.opacity(0.6))
|
||||
.cornerRadius(8)
|
||||
Text("+\(document.images.count - 6)")
|
||||
.foregroundColor(.white)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(12)
|
||||
.shadow(radius: 2)
|
||||
}
|
||||
|
||||
// Associations
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Associations")
|
||||
|
||||
if let residenceAddress = document.residenceAddress {
|
||||
detailRow(label: "Residence", value: residenceAddress)
|
||||
}
|
||||
if let contractorName = document.contractorName {
|
||||
detailRow(label: "Contractor", value: contractorName)
|
||||
}
|
||||
if let contractorPhone = document.contractorPhone {
|
||||
detailRow(label: "Contractor Phone", value: contractorPhone)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(12)
|
||||
.shadow(radius: 2)
|
||||
|
||||
// Additional Information
|
||||
if document.tags != nil || document.notes != nil {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Additional Information")
|
||||
|
||||
if let tags = document.tags, !tags.isEmpty {
|
||||
detailRow(label: "Tags", value: tags)
|
||||
}
|
||||
if let notes = document.notes, !notes.isEmpty {
|
||||
detailRow(label: "Notes", value: notes)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(12)
|
||||
.shadow(radius: 2)
|
||||
}
|
||||
|
||||
// File Information
|
||||
if document.fileUrl != nil {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Attached File")
|
||||
|
||||
if let fileType = document.fileType {
|
||||
detailRow(label: "File Type", value: fileType)
|
||||
}
|
||||
if let fileSize = document.fileSize {
|
||||
detailRow(label: "File Size", value: formatFileSize(bytes: Int(fileSize)))
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
// TODO: Download file
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
Text("Download File")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.blue)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(12)
|
||||
.shadow(radius: 2)
|
||||
}
|
||||
|
||||
// Metadata
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Metadata")
|
||||
|
||||
if let uploadedBy = document.uploadedByUsername {
|
||||
detailRow(label: "Uploaded By", value: uploadedBy)
|
||||
}
|
||||
if let createdAt = document.createdAt {
|
||||
detailRow(label: "Created", value: createdAt)
|
||||
}
|
||||
if let updatedAt = document.updatedAt {
|
||||
detailRow(label: "Updated", value: updatedAt)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(12)
|
||||
.shadow(radius: 2)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func warrantyStatusCard(document: Document) -> some View {
|
||||
let daysUntilExpiration = document.daysUntilExpiration?.int32Value ?? 0
|
||||
let statusColor = getStatusColor(isActive: document.isActive, daysUntilExpiration: daysUntilExpiration)
|
||||
let statusText = getStatusText(isActive: document.isActive, daysUntilExpiration: daysUntilExpiration)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Status")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(statusText)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(statusColor)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if document.isActive && daysUntilExpiration >= 0 {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("Days Remaining")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(daysUntilExpiration)")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(statusColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(statusColor.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detailRow(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(value)
|
||||
.font(.body)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func getStatusColor(isActive: Bool, daysUntilExpiration: Int32) -> Color {
|
||||
if !isActive {
|
||||
return .gray
|
||||
} else if daysUntilExpiration < 0 {
|
||||
return .red
|
||||
} else if daysUntilExpiration < 30 {
|
||||
return .orange
|
||||
} else if daysUntilExpiration < 90 {
|
||||
return .yellow
|
||||
} else {
|
||||
return .green
|
||||
}
|
||||
}
|
||||
|
||||
private func getStatusText(isActive: Bool, daysUntilExpiration: Int32) -> String {
|
||||
if !isActive {
|
||||
return "Inactive"
|
||||
} else if daysUntilExpiration < 0 {
|
||||
return "Expired"
|
||||
} else if daysUntilExpiration < 30 {
|
||||
return "Expiring Soon"
|
||||
} else {
|
||||
return "Active"
|
||||
}
|
||||
}
|
||||
|
||||
private func formatFileSize(bytes: Int) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.allowedUnits = [.useAll]
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: Int64(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
// Helper enums for display names
|
||||
struct DocumentTypeHelper {
|
||||
static func displayName(for value: String) -> String {
|
||||
switch value {
|
||||
case "warranty": return "Warranty"
|
||||
case "manual": return "User Manual"
|
||||
case "receipt": return "Receipt/Invoice"
|
||||
case "inspection": return "Inspection Report"
|
||||
case "permit": return "Permit"
|
||||
case "deed": return "Deed/Title"
|
||||
case "insurance": return "Insurance"
|
||||
case "contract": return "Contract"
|
||||
case "photo": return "Photo"
|
||||
default: return "Other"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DocumentCategoryHelper {
|
||||
static func displayName(for value: String) -> String {
|
||||
switch value {
|
||||
case "appliance": return "Appliance"
|
||||
case "hvac": return "HVAC"
|
||||
case "plumbing": return "Plumbing"
|
||||
case "electrical": return "Electrical"
|
||||
case "roofing": return "Roofing"
|
||||
case "structural": return "Structural"
|
||||
case "landscaping": return "Landscaping"
|
||||
case "general": return "General"
|
||||
default: return "Other"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simple image viewer
|
||||
struct ImageViewerSheet: View {
|
||||
let images: [DocumentImage]
|
||||
@Binding var selectedIndex: Int
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
TabView(selection: $selectedIndex) {
|
||||
ForEach(Array(images.enumerated()), id: \.element.id) { index, image in
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
AsyncImage(url: URL(string: image.imageUrl)) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
case .failure:
|
||||
VStack {
|
||||
Image(systemName: "photo")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.gray)
|
||||
Text("Failed to load image")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
case .empty:
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
@unknown default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page)
|
||||
.indexViewStyle(.page(backgroundDisplayMode: .always))
|
||||
.navigationTitle("Image \(selectedIndex + 1) of \(images.count)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Done") {
|
||||
onDismiss()
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.toolbarBackground(Color.black.opacity(0.8), for: .navigationBar)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
}
|
||||
}
|
||||
}
|
||||
300
iosApp/iosApp/Documents/DocumentViewModelWrapper.swift
Normal file
300
iosApp/iosApp/Documents/DocumentViewModelWrapper.swift
Normal file
@@ -0,0 +1,300 @@
|
||||
import Foundation
|
||||
import ComposeApp
|
||||
import SwiftUI
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
private let documentApi = DocumentApi(client: ApiClient_iosKt.createHttpClient())
|
||||
|
||||
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
|
||||
) {
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
DispatchQueue.main.async {
|
||||
self.documentsState = DocumentStateError(message: "Not authenticated")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.documentsState = DocumentStateLoading()
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await documentApi.getDocuments(
|
||||
token: token,
|
||||
residenceId: residenceId != nil ? KotlinInt(integerLiteral: Int(residenceId!)) : nil,
|
||||
documentType: documentType,
|
||||
category: category,
|
||||
contractorId: contractorId != nil ? KotlinInt(integerLiteral: Int(contractorId!)) : nil,
|
||||
isActive: isActive != nil ? KotlinBoolean(bool: isActive!) : nil,
|
||||
expiringSoon: expiringSoon != nil ? KotlinInt(integerLiteral: Int(expiringSoon!)) : nil,
|
||||
tags: tags,
|
||||
search: search
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
if let success = result as? ApiResultSuccess<DocumentListResponse> {
|
||||
let documents = success.data?.results as? [Document] ?? []
|
||||
self.documentsState = DocumentStateSuccess(documents: documents)
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.documentsState = DocumentStateError(message: error.message)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.documentsState = DocumentStateError(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadDocumentDetail(id: Int32) {
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
DispatchQueue.main.async {
|
||||
self.documentDetailState = DocumentDetailStateError(message: "Not authenticated")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.documentDetailState = DocumentDetailStateLoading()
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await documentApi.getDocument(token: token, id: id)
|
||||
|
||||
await MainActor.run {
|
||||
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
||||
self.documentDetailState = DocumentDetailStateSuccess(document: document)
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.documentDetailState = DocumentDetailStateError(message: error.message)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
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
|
||||
) {
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
DispatchQueue.main.async {
|
||||
self.updateState = UpdateStateError(message: "Not authenticated")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.updateState = UpdateStateLoading()
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await documentApi.updateDocument(
|
||||
token: token,
|
||||
id: id,
|
||||
title: title,
|
||||
documentType: documentType,
|
||||
description: description,
|
||||
category: category,
|
||||
tags: tags,
|
||||
notes: notes,
|
||||
contractorId: nil,
|
||||
isActive: KotlinBoolean(bool: isActive),
|
||||
itemName: itemName,
|
||||
modelNumber: modelNumber,
|
||||
serialNumber: serialNumber,
|
||||
provider: provider,
|
||||
providerContact: providerContact,
|
||||
claimPhone: claimPhone,
|
||||
claimEmail: claimEmail,
|
||||
claimWebsite: claimWebsite,
|
||||
purchaseDate: purchaseDate,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
fileBytes: nil,
|
||||
fileName: nil,
|
||||
mimeType: nil
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
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 = result as? ApiResultError {
|
||||
self.updateState = UpdateStateError(message: error.message)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.updateState = UpdateStateError(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteDocument(id: Int32) {
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
DispatchQueue.main.async {
|
||||
self.deleteState = DeleteStateError(message: "Not authenticated")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.deleteState = DeleteStateLoading()
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await documentApi.deleteDocument(token: token, id: id)
|
||||
|
||||
await MainActor.run {
|
||||
if result is ApiResultSuccess<KotlinUnit> {
|
||||
self.deleteState = DeleteStateSuccess()
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.deleteState = DeleteStateError(message: error.message)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.deleteState = DeleteStateError(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resetUpdateState() {
|
||||
DispatchQueue.main.async {
|
||||
self.updateState = UpdateStateIdle()
|
||||
}
|
||||
}
|
||||
|
||||
func resetDeleteState() {
|
||||
DispatchQueue.main.async {
|
||||
self.deleteState = DeleteStateIdle()
|
||||
}
|
||||
}
|
||||
|
||||
func deleteDocumentImage(imageId: Int32) {
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
DispatchQueue.main.async {
|
||||
self.deleteImageState = DeleteImageStateError(message: "Not authenticated")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.deleteImageState = DeleteImageStateLoading()
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await documentApi.deleteDocumentImage(token: token, imageId: imageId)
|
||||
|
||||
await MainActor.run {
|
||||
if result is ApiResultSuccess<KotlinUnit> {
|
||||
self.deleteImageState = DeleteImageStateSuccess()
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.deleteImageState = DeleteImageStateError(message: error.message)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.deleteImageState = DeleteImageStateError(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resetDeleteImageState() {
|
||||
DispatchQueue.main.async {
|
||||
self.deleteImageState = DeleteImageStateIdle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,10 @@ struct WarrantiesTabContent: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppSpacing.sm) {
|
||||
ForEach(filteredWarranties, id: \.id) { warranty in
|
||||
WarrantyCard(document: warranty)
|
||||
NavigationLink(destination: DocumentDetailView(documentId: warranty.id?.int32Value ?? 0)) {
|
||||
WarrantyCard(document: warranty)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
@@ -286,7 +289,10 @@ struct DocumentsTabContent: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppSpacing.sm) {
|
||||
ForEach(filteredDocuments, id: \.id) { document in
|
||||
DocumentCard(document: document)
|
||||
NavigationLink(destination: DocumentDetailView(documentId: document.id?.int32Value ?? 0)) {
|
||||
DocumentCard(document: document)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
|
||||
330
iosApp/iosApp/Documents/EditDocumentView.swift
Normal file
330
iosApp/iosApp/Documents/EditDocumentView.swift
Normal file
@@ -0,0 +1,330 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
import PhotosUI
|
||||
|
||||
struct EditDocumentView: View {
|
||||
let document: Document
|
||||
@StateObject private var viewModel = DocumentViewModelWrapper()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var title: String
|
||||
@State private var description: String
|
||||
@State private var category: String?
|
||||
@State private var tags: String
|
||||
@State private var notes: String
|
||||
@State private var isActive: Bool
|
||||
|
||||
// Image management
|
||||
@State private var existingImages: [DocumentImage] = []
|
||||
@State private var imagesToDelete: Set<Int32> = []
|
||||
@State private var showImagePicker = false
|
||||
@State private var showCamera = false
|
||||
|
||||
// Warranty-specific fields
|
||||
@State private var itemName: String
|
||||
@State private var modelNumber: String
|
||||
@State private var serialNumber: String
|
||||
@State private var provider: String
|
||||
@State private var providerContact: String
|
||||
@State private var claimPhone: String
|
||||
@State private var claimEmail: String
|
||||
@State private var claimWebsite: String
|
||||
@State private var purchaseDate: String
|
||||
@State private var startDate: String
|
||||
@State private var endDate: String
|
||||
|
||||
@State private var showCategoryPicker = false
|
||||
@State private var showAlert = false
|
||||
@State private var alertMessage = ""
|
||||
|
||||
init(document: Document) {
|
||||
self.document = document
|
||||
_title = State(initialValue: document.title)
|
||||
_description = State(initialValue: document.description_ ?? "")
|
||||
_category = State(initialValue: document.category)
|
||||
_tags = State(initialValue: document.tags ?? "")
|
||||
_notes = State(initialValue: document.notes ?? "")
|
||||
_isActive = State(initialValue: document.isActive)
|
||||
|
||||
_itemName = State(initialValue: document.itemName ?? "")
|
||||
_modelNumber = State(initialValue: document.modelNumber ?? "")
|
||||
_serialNumber = State(initialValue: document.serialNumber ?? "")
|
||||
_provider = State(initialValue: document.provider ?? "")
|
||||
_providerContact = State(initialValue: document.providerContact ?? "")
|
||||
_claimPhone = State(initialValue: document.claimPhone ?? "")
|
||||
_claimEmail = State(initialValue: document.claimEmail ?? "")
|
||||
_claimWebsite = State(initialValue: document.claimWebsite ?? "")
|
||||
_purchaseDate = State(initialValue: document.purchaseDate ?? "")
|
||||
_startDate = State(initialValue: document.startDate ?? "")
|
||||
_endDate = State(initialValue: document.endDate ?? "")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Form {
|
||||
// Document Type (Read-only)
|
||||
Section {
|
||||
HStack {
|
||||
Text("Document Type")
|
||||
Spacer()
|
||||
Text(DocumentTypeHelper.displayName(for: document.documentType))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text("Document type cannot be changed")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
// Basic Information
|
||||
Section("Basic Information") {
|
||||
TextField("Title *", text: $title)
|
||||
|
||||
if document.documentType == "warranty" {
|
||||
Button(action: { showCategoryPicker = true }) {
|
||||
HStack {
|
||||
Text("Category")
|
||||
Spacer()
|
||||
Text(category.map { DocumentCategoryHelper.displayName(for: $0) } ?? "Select category")
|
||||
.foregroundColor(.secondary)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextField("Description", text: $description, axis: .vertical)
|
||||
.lineLimit(3...5)
|
||||
}
|
||||
|
||||
// Warranty-specific sections
|
||||
if document.documentType == "warranty" {
|
||||
Section("Item Details") {
|
||||
TextField("Item Name", text: $itemName)
|
||||
TextField("Model Number", text: $modelNumber)
|
||||
TextField("Serial Number", text: $serialNumber)
|
||||
TextField("Provider/Manufacturer", text: $provider)
|
||||
TextField("Provider Contact", text: $providerContact)
|
||||
}
|
||||
|
||||
Section("Claim Information") {
|
||||
TextField("Claim Phone", text: $claimPhone)
|
||||
.keyboardType(.phonePad)
|
||||
TextField("Claim Email", text: $claimEmail)
|
||||
.keyboardType(.emailAddress)
|
||||
.textInputAutocapitalization(.never)
|
||||
TextField("Claim Website", text: $claimWebsite)
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
|
||||
Section("Important Dates") {
|
||||
TextField("Purchase Date (YYYY-MM-DD)", text: $purchaseDate)
|
||||
TextField("Start Date (YYYY-MM-DD)", text: $startDate)
|
||||
TextField("End Date (YYYY-MM-DD)", text: $endDate)
|
||||
}
|
||||
}
|
||||
|
||||
// Image Management
|
||||
Section {
|
||||
let totalImages = existingImages.count - imagesToDelete.count
|
||||
let imageCountText = "\(totalImages)/10"
|
||||
|
||||
HStack {
|
||||
Text("Photos (\(imageCountText))")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Existing Images
|
||||
if !existingImages.isEmpty {
|
||||
Text("Existing Images")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
ForEach(existingImages, id: \.id) { image in
|
||||
if let imageId = image.id, !imagesToDelete.contains(imageId.int32Value) {
|
||||
HStack {
|
||||
AsyncImage(url: URL(string: image.imageUrl)) { phase in
|
||||
switch phase {
|
||||
case .success(let img):
|
||||
img
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
case .failure:
|
||||
Image(systemName: "photo")
|
||||
.foregroundColor(.gray)
|
||||
case .empty:
|
||||
ProgressView()
|
||||
@unknown default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.frame(width: 60, height: 60)
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
|
||||
Text(image.caption ?? "Image \(imageId)")
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
imagesToDelete.insert(imageId.int32Value)
|
||||
}) {
|
||||
Image(systemName: "trash")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Note: iOS image upload will be available in a future update. You can only delete existing images for now.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} header: {
|
||||
Text("Images")
|
||||
}
|
||||
|
||||
// Additional Information
|
||||
Section("Additional Information") {
|
||||
TextField("Tags (comma-separated)", text: $tags)
|
||||
TextField("Notes", text: $notes, axis: .vertical)
|
||||
.lineLimit(3...5)
|
||||
|
||||
Toggle("Active", isOn: $isActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Edit Document")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
existingImages = document.images
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
saveDocument()
|
||||
}
|
||||
.disabled(title.isEmpty || viewModel.updateState is UpdateStateLoading)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showCategoryPicker) {
|
||||
categoryPickerSheet
|
||||
}
|
||||
.alert("Update Document", isPresented: $showAlert) {
|
||||
Button("OK") {
|
||||
if viewModel.updateState is UpdateStateSuccess {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text(alertMessage)
|
||||
}
|
||||
.onReceive(viewModel.$updateState) { newState in
|
||||
if newState is UpdateStateSuccess {
|
||||
alertMessage = "Document updated successfully"
|
||||
showAlert = true
|
||||
} else if let errorState = newState as? UpdateStateError {
|
||||
alertMessage = errorState.message
|
||||
showAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var categoryPickerSheet: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
Button("None") {
|
||||
category = nil
|
||||
showCategoryPicker = false
|
||||
}
|
||||
|
||||
ForEach(allCategories, id: \.value) { cat in
|
||||
Button(action: {
|
||||
category = cat.value
|
||||
showCategoryPicker = false
|
||||
}) {
|
||||
HStack {
|
||||
Text(cat.displayName)
|
||||
Spacer()
|
||||
if category == cat.value {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Category")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
showCategoryPicker = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var allCategories: [(value: String, displayName: String)] {
|
||||
[
|
||||
("appliance", "Appliance"),
|
||||
("hvac", "HVAC"),
|
||||
("plumbing", "Plumbing"),
|
||||
("electrical", "Electrical"),
|
||||
("roofing", "Roofing"),
|
||||
("structural", "Structural"),
|
||||
("landscaping", "Landscaping"),
|
||||
("general", "General"),
|
||||
("other", "Other")
|
||||
]
|
||||
}
|
||||
|
||||
private func saveDocument() {
|
||||
guard !title.isEmpty else {
|
||||
alertMessage = "Title is required"
|
||||
showAlert = true
|
||||
return
|
||||
}
|
||||
|
||||
guard let documentId = document.id else {
|
||||
alertMessage = "Invalid document ID"
|
||||
showAlert = true
|
||||
return
|
||||
}
|
||||
|
||||
// First, delete any images marked for deletion
|
||||
for imageId in imagesToDelete {
|
||||
viewModel.deleteDocumentImage(imageId: imageId)
|
||||
}
|
||||
|
||||
// Then update the document
|
||||
viewModel.updateDocument(
|
||||
id: documentId.int32Value,
|
||||
title: title,
|
||||
documentType: document.documentType,
|
||||
description: description.isEmpty ? nil : description,
|
||||
category: category,
|
||||
tags: tags.isEmpty ? nil : tags,
|
||||
notes: notes.isEmpty ? nil : notes,
|
||||
isActive: isActive,
|
||||
itemName: itemName.isEmpty ? nil : itemName,
|
||||
modelNumber: modelNumber.isEmpty ? nil : modelNumber,
|
||||
serialNumber: serialNumber.isEmpty ? nil : serialNumber,
|
||||
provider: provider.isEmpty ? nil : provider,
|
||||
providerContact: providerContact.isEmpty ? nil : providerContact,
|
||||
claimPhone: claimPhone.isEmpty ? nil : claimPhone,
|
||||
claimEmail: claimEmail.isEmpty ? nil : claimEmail,
|
||||
claimWebsite: claimWebsite.isEmpty ? nil : claimWebsite,
|
||||
purchaseDate: purchaseDate.isEmpty ? nil : purchaseDate,
|
||||
startDate: startDate.isEmpty ? nil : startDate,
|
||||
endDate: endDate.isEmpty ? nil : endDate
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user