Add documents and warranties feature with image upload support
- Implement complete document management system for warranties, manuals, receipts, and other property documents - Add DocumentsScreen with tabbed interface for warranties and documents - Add AddDocumentScreen with comprehensive form including warranty-specific fields - Integrate image upload functionality (camera + gallery, up to 5 images) - Fix FAB visibility by adding bottom padding to account for navigation bar - Fix content being cut off by bottom navigation bar (96dp padding) - Add DocumentViewModel for state management with CRUD operations - Add DocumentApi for backend communication with multipart image upload - Add Document model with comprehensive field support - Update navigation to include document routes - Add iOS DocumentsWarrantiesView and AddDocumentView for cross-platform support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
461
iosApp/iosApp/Documents/AddDocumentView.swift
Normal file
461
iosApp/iosApp/Documents/AddDocumentView.swift
Normal file
@@ -0,0 +1,461 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
import PhotosUI
|
||||
|
||||
struct AddDocumentView: View {
|
||||
let residenceId: Int32?
|
||||
let initialDocumentType: String
|
||||
@Binding var isPresented: Bool
|
||||
@ObservedObject var documentViewModel: DocumentViewModel
|
||||
@StateObject private var residenceViewModel = ResidenceViewModel()
|
||||
|
||||
// Form fields
|
||||
@State private var title = ""
|
||||
@State private var description = ""
|
||||
@State private var selectedDocumentType: String
|
||||
@State private var selectedCategory: String? = nil
|
||||
@State private var notes = ""
|
||||
@State private var tags = ""
|
||||
|
||||
// Warranty-specific fields
|
||||
@State private var itemName = ""
|
||||
@State private var modelNumber = ""
|
||||
@State private var serialNumber = ""
|
||||
@State private var provider = ""
|
||||
@State private var providerContact = ""
|
||||
@State private var claimPhone = ""
|
||||
@State private var claimEmail = ""
|
||||
@State private var claimWebsite = ""
|
||||
@State private var purchaseDate = ""
|
||||
@State private var startDate = ""
|
||||
@State private var endDate = ""
|
||||
|
||||
// Residence selection (if residenceId is nil)
|
||||
@State private var selectedResidenceId: Int? = nil
|
||||
|
||||
// File picker
|
||||
@State private var selectedPhotoItems: [PhotosPickerItem] = []
|
||||
@State private var selectedImages: [UIImage] = []
|
||||
@State private var showCamera = false
|
||||
|
||||
// Validation errors
|
||||
@State private var titleError = ""
|
||||
@State private var itemNameError = ""
|
||||
@State private var providerError = ""
|
||||
@State private var residenceError = ""
|
||||
|
||||
// UI state
|
||||
@State private var isCreating = false
|
||||
@State private var createError: String? = nil
|
||||
@State private var showValidationAlert = false
|
||||
@State private var validationAlertMessage = ""
|
||||
|
||||
init(residenceId: Int32?, initialDocumentType: String, isPresented: Binding<Bool>, documentViewModel: DocumentViewModel) {
|
||||
self.residenceId = residenceId
|
||||
self.initialDocumentType = initialDocumentType
|
||||
self._isPresented = isPresented
|
||||
self.documentViewModel = documentViewModel
|
||||
self._selectedDocumentType = State(initialValue: initialDocumentType)
|
||||
}
|
||||
|
||||
var isWarranty: Bool {
|
||||
selectedDocumentType == "warranty"
|
||||
}
|
||||
|
||||
var needsResidenceSelection: Bool {
|
||||
residenceId == nil
|
||||
}
|
||||
|
||||
var residencesArray: [(id: Int, name: String)] {
|
||||
guard let residences = residenceViewModel.myResidences?.residences else {
|
||||
return []
|
||||
}
|
||||
return residences.map { residenceWithTasks in
|
||||
(id: Int(residenceWithTasks.id), name: residenceWithTasks.name)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
// Residence Selection (if needed)
|
||||
if needsResidenceSelection {
|
||||
Section(header: Text("Residence")) {
|
||||
if residenceViewModel.isLoading {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Loading residences...")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else if let error = residenceViewModel.errorMessage {
|
||||
Text("Error: \(error)")
|
||||
.foregroundColor(.red)
|
||||
} else if !residencesArray.isEmpty {
|
||||
Picker("Residence", selection: $selectedResidenceId) {
|
||||
Text("Select Residence").tag(nil as Int?)
|
||||
ForEach(residencesArray, id: \.id) { residence in
|
||||
Text(residence.name).tag(residence.id as Int?)
|
||||
}
|
||||
}
|
||||
if !residenceError.isEmpty {
|
||||
Text(residenceError)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Document Type
|
||||
Section(header: Text("Document Type")) {
|
||||
Picker("Type", selection: $selectedDocumentType) {
|
||||
ForEach(DocumentType.allCases, id: \.self) { type in
|
||||
Text(type.displayName).tag(type.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Basic Information
|
||||
Section(header: Text("Basic Information")) {
|
||||
TextField("Title", text: $title)
|
||||
if !titleError.isEmpty {
|
||||
Text(titleError)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
TextField("Description (optional)", text: $description, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
}
|
||||
|
||||
// Warranty-specific fields
|
||||
if isWarranty {
|
||||
Section(header: Text("Warranty Details")) {
|
||||
TextField("Item Name", text: $itemName)
|
||||
if !itemNameError.isEmpty {
|
||||
Text(itemNameError)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
TextField("Model Number (optional)", text: $modelNumber)
|
||||
TextField("Serial Number (optional)", text: $serialNumber)
|
||||
|
||||
TextField("Provider/Company", text: $provider)
|
||||
if !providerError.isEmpty {
|
||||
Text(providerError)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
TextField("Provider Contact (optional)", text: $providerContact)
|
||||
TextField("Claim Phone (optional)", text: $claimPhone)
|
||||
.keyboardType(.phonePad)
|
||||
TextField("Claim Email (optional)", text: $claimEmail)
|
||||
.keyboardType(.emailAddress)
|
||||
.textInputAutocapitalization(.never)
|
||||
TextField("Claim Website (optional)", text: $claimWebsite)
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
|
||||
Section(header: Text("Warranty Dates")) {
|
||||
TextField("Purchase Date (YYYY-MM-DD)", text: $purchaseDate)
|
||||
.keyboardType(.numbersAndPunctuation)
|
||||
TextField("Warranty Start Date (YYYY-MM-DD)", text: $startDate)
|
||||
.keyboardType(.numbersAndPunctuation)
|
||||
TextField("Warranty End Date (YYYY-MM-DD)", text: $endDate)
|
||||
.keyboardType(.numbersAndPunctuation)
|
||||
}
|
||||
}
|
||||
|
||||
// Category
|
||||
if isWarranty || ["inspection", "manual", "receipt"].contains(selectedDocumentType) {
|
||||
Section(header: Text("Category")) {
|
||||
Picker("Category", selection: $selectedCategory) {
|
||||
Text("None").tag(nil as String?)
|
||||
ForEach(DocumentCategory.allCases, id: \.self) { category in
|
||||
Text(category.displayName).tag(category.value as String?)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional Information
|
||||
Section(header: Text("Additional Information")) {
|
||||
TextField("Tags (comma-separated)", text: $tags)
|
||||
TextField("Notes (optional)", text: $notes, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
}
|
||||
|
||||
// Images/Files Section
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: {
|
||||
showCamera = true
|
||||
}) {
|
||||
Label("Take Photo", systemImage: "camera")
|
||||
.frame(maxWidth: .infinity)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
PhotosPicker(
|
||||
selection: $selectedPhotoItems,
|
||||
maxSelectionCount: 5,
|
||||
matching: .images,
|
||||
photoLibrary: .shared()
|
||||
) {
|
||||
Label("Library", systemImage: "photo.on.rectangle.angled")
|
||||
.frame(maxWidth: .infinity)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.onChange(of: selectedPhotoItems) { newItems in
|
||||
Task {
|
||||
selectedImages = []
|
||||
for item in newItems {
|
||||
if let data = try? await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) {
|
||||
selectedImages.append(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Display selected images
|
||||
if !selectedImages.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(selectedImages.indices, id: \.self) { index in
|
||||
ImageThumbnailView(
|
||||
image: selectedImages[index],
|
||||
onRemove: {
|
||||
withAnimation {
|
||||
selectedImages.remove(at: index)
|
||||
selectedPhotoItems.remove(at: index)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Photos (\(selectedImages.count)/5)")
|
||||
} footer: {
|
||||
Text("Add up to 5 photos of the \(isWarranty ? "warranty" : "document").")
|
||||
}
|
||||
|
||||
// Error message
|
||||
if let error = createError {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(isWarranty ? "Add Warranty" : "Add Document")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
isPresented = false
|
||||
}
|
||||
.disabled(isCreating)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(isCreating ? "Saving..." : "Save") {
|
||||
saveDocument()
|
||||
}
|
||||
.disabled(isCreating)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if needsResidenceSelection {
|
||||
residenceViewModel.loadMyResidences()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showCamera) {
|
||||
CameraPickerView { image in
|
||||
if selectedImages.count < 5 {
|
||||
selectedImages.append(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Validation Error", isPresented: $showValidationAlert) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text(validationAlertMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveDocument() {
|
||||
print("🔵 saveDocument called")
|
||||
|
||||
// Reset errors
|
||||
titleError = ""
|
||||
itemNameError = ""
|
||||
providerError = ""
|
||||
residenceError = ""
|
||||
createError = nil
|
||||
|
||||
var hasError = false
|
||||
|
||||
// Validate residence
|
||||
let actualResidenceId: Int32
|
||||
if needsResidenceSelection {
|
||||
print("🔵 needsResidenceSelection: true, selectedResidenceId: \(String(describing: selectedResidenceId))")
|
||||
if selectedResidenceId == nil {
|
||||
residenceError = "Please select a residence"
|
||||
hasError = true
|
||||
print("🔴 Validation failed: No residence selected")
|
||||
return
|
||||
} else {
|
||||
actualResidenceId = Int32(selectedResidenceId!)
|
||||
}
|
||||
} else {
|
||||
print("🔵 Using provided residenceId: \(String(describing: residenceId))")
|
||||
actualResidenceId = residenceId!
|
||||
}
|
||||
|
||||
// Validate title
|
||||
if title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
titleError = "Title is required"
|
||||
hasError = true
|
||||
print("🔴 Validation failed: Title is empty")
|
||||
}
|
||||
|
||||
// Validate warranty fields
|
||||
if isWarranty {
|
||||
print("🔵 isWarranty: true")
|
||||
if itemName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
itemNameError = "Item name is required for warranties"
|
||||
hasError = true
|
||||
print("🔴 Validation failed: Item name is empty")
|
||||
}
|
||||
if provider.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
providerError = "Provider is required for warranties"
|
||||
hasError = true
|
||||
print("🔴 Validation failed: Provider is empty")
|
||||
}
|
||||
}
|
||||
|
||||
if hasError {
|
||||
print("🔴 Validation failed, returning")
|
||||
// Show alert with all validation errors
|
||||
var errors: [String] = []
|
||||
if !residenceError.isEmpty { errors.append(residenceError) }
|
||||
if !titleError.isEmpty { errors.append(titleError) }
|
||||
if !itemNameError.isEmpty { errors.append(itemNameError) }
|
||||
if !providerError.isEmpty { errors.append(providerError) }
|
||||
|
||||
validationAlertMessage = errors.joined(separator: "\n")
|
||||
showValidationAlert = true
|
||||
return
|
||||
}
|
||||
|
||||
print("🟢 Validation passed, creating document...")
|
||||
isCreating = true
|
||||
|
||||
// Prepare file data if images are available
|
||||
var fileBytesList: [KotlinByteArray]? = nil
|
||||
var fileNamesList: [String]? = nil
|
||||
var mimeTypesList: [String]? = nil
|
||||
|
||||
if !selectedImages.isEmpty {
|
||||
var bytesList: [KotlinByteArray] = []
|
||||
var namesList: [String] = []
|
||||
var typesList: [String] = []
|
||||
|
||||
for (index, image) in selectedImages.enumerated() {
|
||||
if let imageData = image.jpegData(compressionQuality: 0.8) {
|
||||
bytesList.append(KotlinByteArray(data: imageData))
|
||||
namesList.append("image_\(index).jpg")
|
||||
typesList.append("image/jpeg")
|
||||
}
|
||||
}
|
||||
|
||||
if !bytesList.isEmpty {
|
||||
fileBytesList = bytesList
|
||||
fileNamesList = namesList
|
||||
mimeTypesList = typesList
|
||||
}
|
||||
}
|
||||
|
||||
// Call the API
|
||||
Task {
|
||||
do {
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
await MainActor.run {
|
||||
createError = "Not authenticated"
|
||||
isCreating = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let result = try await DocumentApi(client: ApiClient_iosKt.createHttpClient()).createDocument(
|
||||
token: token,
|
||||
title: title,
|
||||
documentType: selectedDocumentType,
|
||||
residenceId: actualResidenceId,
|
||||
description: description.isEmpty ? nil : description,
|
||||
category: selectedCategory,
|
||||
tags: tags.isEmpty ? nil : tags,
|
||||
notes: notes.isEmpty ? nil : notes,
|
||||
contractorId: nil,
|
||||
isActive: true,
|
||||
itemName: isWarranty ? itemName : nil,
|
||||
modelNumber: modelNumber.isEmpty ? nil : modelNumber,
|
||||
serialNumber: serialNumber.isEmpty ? nil : serialNumber,
|
||||
provider: isWarranty ? provider : nil,
|
||||
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,
|
||||
fileBytes: nil,
|
||||
fileName: nil,
|
||||
mimeType: nil,
|
||||
fileBytesList: fileBytesList,
|
||||
fileNamesList: fileNamesList,
|
||||
mimeTypesList: mimeTypesList
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
if result is ApiResultSuccess<Document> {
|
||||
print("🟢 Document created successfully!")
|
||||
// Reload documents
|
||||
documentViewModel.loadDocuments(
|
||||
residenceId: residenceId,
|
||||
documentType: isWarranty ? "warranty" : nil
|
||||
)
|
||||
isPresented = false
|
||||
} else if let error = result as? ApiResultError {
|
||||
print("🔴 API Error: \(error.message)")
|
||||
createError = error.message
|
||||
isCreating = false
|
||||
} else {
|
||||
print("🔴 Unknown result type: \(type(of: result))")
|
||||
createError = "Unknown error occurred"
|
||||
isCreating = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("🔴 Exception: \(error.localizedDescription)")
|
||||
await MainActor.run {
|
||||
createError = error.localizedDescription
|
||||
isCreating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
184
iosApp/iosApp/Documents/DocumentViewModel.swift
Normal file
184
iosApp/iosApp/Documents/DocumentViewModel.swift
Normal file
@@ -0,0 +1,184 @@
|
||||
import Foundation
|
||||
import ComposeApp
|
||||
|
||||
class DocumentViewModel: ObservableObject {
|
||||
@Published var documents: [Document] = []
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
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 {
|
||||
errorMessage = "Not authenticated"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
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> {
|
||||
self.documents = success.data?.results as? [Document] ?? []
|
||||
self.isLoading = false
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.errorMessage = error.message
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createDocument(
|
||||
title: String,
|
||||
documentType: String,
|
||||
residenceId: Int32,
|
||||
description: String? = nil,
|
||||
tags: String? = nil,
|
||||
contractorId: Int32? = nil,
|
||||
fileData: Data? = nil,
|
||||
fileName: String? = nil,
|
||||
mimeType: String? = nil
|
||||
) {
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
errorMessage = "Not authenticated"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await documentApi.createDocument(
|
||||
token: token,
|
||||
title: title,
|
||||
documentType: documentType,
|
||||
residenceId: Int32(residenceId),
|
||||
description: description,
|
||||
category: nil,
|
||||
tags: tags,
|
||||
notes: nil,
|
||||
contractorId: contractorId != nil ? KotlinInt(integerLiteral: Int(contractorId!)) : nil,
|
||||
isActive: true,
|
||||
itemName: nil,
|
||||
modelNumber: nil,
|
||||
serialNumber: nil,
|
||||
provider: nil,
|
||||
providerContact: nil,
|
||||
claimPhone: nil,
|
||||
claimEmail: nil,
|
||||
claimWebsite: nil,
|
||||
purchaseDate: nil,
|
||||
startDate: nil,
|
||||
endDate: nil,
|
||||
fileBytes: nil,
|
||||
fileName: fileName,
|
||||
mimeType: mimeType,
|
||||
fileBytesList: nil,
|
||||
fileNamesList: nil,
|
||||
mimeTypesList: nil
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
if result is ApiResultSuccess<Document> {
|
||||
self.loadDocuments()
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.errorMessage = error.message
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteDocument(id: Int32) {
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
errorMessage = "Not authenticated"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await documentApi.deleteDocument(token: token, id: id)
|
||||
|
||||
await MainActor.run {
|
||||
if result is ApiResultSuccess<KotlinUnit> {
|
||||
self.loadDocuments()
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.errorMessage = error.message
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadDocument(url: String) -> Task<Data?, Error> {
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
return Task { throw NSError(domain: "Not authenticated", code: 401) }
|
||||
}
|
||||
|
||||
return Task {
|
||||
do {
|
||||
let result = try await documentApi.downloadDocument(token: token, url: url)
|
||||
|
||||
if let success = result as? ApiResultSuccess<KotlinByteArray>, let byteArray = success.data {
|
||||
// Convert Kotlin ByteArray to Swift Data
|
||||
var data = Data()
|
||||
for i in 0..<byteArray.size {
|
||||
data.append(UInt8(bitPattern: byteArray.get(index: i)))
|
||||
}
|
||||
return data
|
||||
} else if let error = result as? ApiResultError {
|
||||
throw NSError(domain: error.message, code: error.code?.intValue ?? 0)
|
||||
}
|
||||
return nil
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
567
iosApp/iosApp/Documents/DocumentsWarrantiesView.swift
Normal file
567
iosApp/iosApp/Documents/DocumentsWarrantiesView.swift
Normal file
@@ -0,0 +1,567 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
enum DocumentWarrantyTab {
|
||||
case warranties
|
||||
case documents
|
||||
}
|
||||
|
||||
struct DocumentsWarrantiesView: View {
|
||||
@StateObject private var documentViewModel = DocumentViewModel()
|
||||
@State private var selectedTab: DocumentWarrantyTab = .warranties
|
||||
@State private var searchText = ""
|
||||
@State private var selectedCategory: String? = nil
|
||||
@State private var selectedDocType: String? = nil
|
||||
@State private var showActiveOnly = true
|
||||
@State private var showFilterMenu = false
|
||||
@State private var showAddSheet = false
|
||||
|
||||
let residenceId: Int32?
|
||||
|
||||
var warranties: [Document] {
|
||||
documentViewModel.documents.filter { $0.documentType == "warranty" }
|
||||
}
|
||||
|
||||
var documents: [Document] {
|
||||
documentViewModel.documents.filter { $0.documentType != "warranty" }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppColors.background.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Segmented Control for Tabs
|
||||
Picker("", selection: $selectedTab) {
|
||||
Label("Warranties", systemImage: "checkmark.shield")
|
||||
.tag(DocumentWarrantyTab.warranties)
|
||||
Label("Documents", systemImage: "doc.text")
|
||||
.tag(DocumentWarrantyTab.documents)
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
.padding(.top, AppSpacing.sm)
|
||||
|
||||
// Search Bar
|
||||
SearchBar(text: $searchText, placeholder: "Search...")
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
.padding(.top, AppSpacing.xs)
|
||||
|
||||
// Active Filters
|
||||
if selectedCategory != nil || selectedDocType != nil || showActiveOnly {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: AppSpacing.xs) {
|
||||
if selectedTab == .warranties && showActiveOnly {
|
||||
FilterChip(
|
||||
title: "Active Only",
|
||||
icon: "checkmark.circle.fill",
|
||||
onRemove: { showActiveOnly = false }
|
||||
)
|
||||
}
|
||||
|
||||
if let category = selectedCategory, selectedTab == .warranties {
|
||||
FilterChip(
|
||||
title: category,
|
||||
onRemove: { selectedCategory = nil }
|
||||
)
|
||||
}
|
||||
|
||||
if let docType = selectedDocType, selectedTab == .documents {
|
||||
FilterChip(
|
||||
title: docType,
|
||||
onRemove: { selectedDocType = nil }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
}
|
||||
.padding(.vertical, AppSpacing.xs)
|
||||
}
|
||||
|
||||
// Content
|
||||
if selectedTab == .warranties {
|
||||
WarrantiesTabContent(
|
||||
viewModel: documentViewModel,
|
||||
searchText: searchText
|
||||
)
|
||||
} else {
|
||||
DocumentsTabContent(
|
||||
viewModel: documentViewModel,
|
||||
searchText: searchText
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Documents & Warranties")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
// Active Filter (for warranties)
|
||||
if selectedTab == .warranties {
|
||||
Button(action: {
|
||||
showActiveOnly.toggle()
|
||||
loadWarranties()
|
||||
}) {
|
||||
Image(systemName: showActiveOnly ? "checkmark.circle.fill" : "checkmark.circle")
|
||||
.foregroundColor(showActiveOnly ? AppColors.success : AppColors.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter Menu
|
||||
Menu {
|
||||
if selectedTab == .warranties {
|
||||
Button(action: {
|
||||
selectedCategory = nil
|
||||
loadWarranties()
|
||||
}) {
|
||||
Label("All Categories", systemImage: selectedCategory == nil ? "checkmark" : "")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(DocumentCategory.allCases, id: \.self) { category in
|
||||
Button(action: {
|
||||
selectedCategory = category.displayName
|
||||
loadWarranties()
|
||||
}) {
|
||||
Label(category.displayName, systemImage: selectedCategory == category.displayName ? "checkmark" : "")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(action: {
|
||||
selectedDocType = nil
|
||||
loadDocuments()
|
||||
}) {
|
||||
Label("All Types", systemImage: selectedDocType == nil ? "checkmark" : "")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(DocumentType.allCases, id: \.self) { type in
|
||||
Button(action: {
|
||||
selectedDocType = type.displayName
|
||||
loadDocuments()
|
||||
}) {
|
||||
Label(type.displayName, systemImage: selectedDocType == type.displayName ? "checkmark" : "")
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
.foregroundColor((selectedCategory != nil || selectedDocType != nil) ? AppColors.primary : AppColors.textSecondary)
|
||||
}
|
||||
|
||||
// Add Button
|
||||
Button(action: {
|
||||
showAddSheet = true
|
||||
}) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(AppColors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadWarranties()
|
||||
loadDocuments()
|
||||
}
|
||||
.onChange(of: selectedTab) { _ in
|
||||
if selectedTab == .warranties {
|
||||
loadWarranties()
|
||||
} else {
|
||||
loadDocuments()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddSheet) {
|
||||
AddDocumentView(
|
||||
residenceId: residenceId,
|
||||
initialDocumentType: selectedTab == .warranties ? "warranty" : "other",
|
||||
isPresented: $showAddSheet,
|
||||
documentViewModel: documentViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadWarranties() {
|
||||
documentViewModel.loadDocuments(
|
||||
residenceId: residenceId,
|
||||
documentType: "warranty",
|
||||
category: selectedCategory,
|
||||
isActive: showActiveOnly ? true : nil
|
||||
)
|
||||
}
|
||||
|
||||
private func loadDocuments() {
|
||||
documentViewModel.loadDocuments(
|
||||
residenceId: residenceId,
|
||||
documentType: selectedDocType
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Warranties Tab
|
||||
struct WarrantiesTabContent: View {
|
||||
@ObservedObject var viewModel: DocumentViewModel
|
||||
let searchText: String
|
||||
|
||||
var filteredWarranties: [Document] {
|
||||
let warranties = viewModel.documents.filter { $0.documentType == "warranty" }
|
||||
if searchText.isEmpty {
|
||||
return warranties
|
||||
}
|
||||
return warranties.filter {
|
||||
$0.title.localizedCaseInsensitiveContains(searchText) ||
|
||||
($0.itemName ?? "").localizedCaseInsensitiveContains(searchText) ||
|
||||
($0.provider ?? "").localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if viewModel.isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.scaleEffect(1.2)
|
||||
Spacer()
|
||||
} else if let error = viewModel.errorMessage {
|
||||
Spacer()
|
||||
ErrorView(message: error, retryAction: { viewModel.loadDocuments() })
|
||||
Spacer()
|
||||
} else if filteredWarranties.isEmpty {
|
||||
Spacer()
|
||||
EmptyStateView(
|
||||
icon: "doc.text.viewfinder",
|
||||
title: "No warranties found",
|
||||
message: "Add warranties to track coverage periods"
|
||||
)
|
||||
Spacer()
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppSpacing.sm) {
|
||||
ForEach(filteredWarranties, id: \.id) { warranty in
|
||||
WarrantyCard(document: warranty)
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Documents Tab
|
||||
struct DocumentsTabContent: View {
|
||||
@ObservedObject var viewModel: DocumentViewModel
|
||||
let searchText: String
|
||||
|
||||
var filteredDocuments: [Document] {
|
||||
if searchText.isEmpty {
|
||||
return viewModel.documents
|
||||
}
|
||||
return viewModel.documents.filter {
|
||||
$0.title.localizedCaseInsensitiveContains(searchText) ||
|
||||
($0.description_ ?? "").localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if viewModel.isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.scaleEffect(1.2)
|
||||
Spacer()
|
||||
} else if let error = viewModel.errorMessage {
|
||||
Spacer()
|
||||
ErrorView(message: error, retryAction: { viewModel.loadDocuments() })
|
||||
Spacer()
|
||||
} else if filteredDocuments.isEmpty {
|
||||
Spacer()
|
||||
EmptyStateView(
|
||||
icon: "doc",
|
||||
title: "No documents found",
|
||||
message: "Add documents related to your residence"
|
||||
)
|
||||
Spacer()
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppSpacing.sm) {
|
||||
ForEach(filteredDocuments, id: \.id) { document in
|
||||
DocumentCard(document: document)
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Warranty Card
|
||||
struct WarrantyCard: View {
|
||||
let document: Document
|
||||
|
||||
var daysUntilExpiration: Int {
|
||||
Int(document.daysUntilExpiration ?? 0)
|
||||
}
|
||||
|
||||
var statusColor: Color {
|
||||
if !document.isActive { return .gray }
|
||||
if daysUntilExpiration < 0 { return AppColors.error }
|
||||
if daysUntilExpiration < 30 { return AppColors.warning }
|
||||
if daysUntilExpiration < 90 { return .yellow }
|
||||
return AppColors.success
|
||||
}
|
||||
|
||||
var statusText: String {
|
||||
if !document.isActive { return "Inactive" }
|
||||
if daysUntilExpiration < 0 { return "Expired" }
|
||||
if daysUntilExpiration < 30 { return "Expiring soon" }
|
||||
return "Active"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppSpacing.sm) {
|
||||
// Header
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(document.title)
|
||||
.font(AppTypography.titleMedium)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
|
||||
Text(document.itemName ?? "")
|
||||
.font(AppTypography.bodyMedium)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Status Badge
|
||||
Text(statusText)
|
||||
.font(AppTypography.labelSmall)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(statusColor)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(statusColor.opacity(0.2))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Details
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Provider")
|
||||
.font(AppTypography.labelSmall)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
Text(document.provider ?? "N/A")
|
||||
.font(AppTypography.bodyMedium)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("Expires")
|
||||
.font(AppTypography.labelSmall)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
Text(document.endDate ?? "N/A")
|
||||
.font(AppTypography.bodyMedium)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
if document.isActive && daysUntilExpiration >= 0 {
|
||||
Text("\(daysUntilExpiration) days remaining")
|
||||
.font(AppTypography.labelMedium)
|
||||
.foregroundColor(statusColor)
|
||||
}
|
||||
|
||||
// Category Badge
|
||||
if let category = document.category {
|
||||
Text(getCategoryDisplayName(category))
|
||||
.font(AppTypography.labelSmall)
|
||||
.foregroundColor(Color(hex: "374151"))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color(hex: "E5E7EB"))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(AppColors.surface)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||
}
|
||||
|
||||
private func getCategoryDisplayName(_ category: String) -> String {
|
||||
return DocumentCategory.companion.fromValue(value: category).displayName
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Document Card
|
||||
struct DocumentCard: View {
|
||||
let document: Document
|
||||
|
||||
var typeColor: Color {
|
||||
switch document.documentType {
|
||||
case "warranty": return .blue
|
||||
case "manual": return .purple
|
||||
case "receipt": return AppColors.success
|
||||
case "inspection": return AppColors.warning
|
||||
default: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
var typeIcon: String {
|
||||
switch document.documentType {
|
||||
case "photo": return "photo"
|
||||
case "warranty", "insurance": return "checkmark.shield"
|
||||
case "manual": return "book"
|
||||
case "receipt": return "receipt"
|
||||
default: return "doc.text"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: AppSpacing.md) {
|
||||
// Document Icon
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(typeColor.opacity(0.1))
|
||||
.frame(width: 56, height: 56)
|
||||
|
||||
Image(systemName: typeIcon)
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(typeColor)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(document.title)
|
||||
.font(AppTypography.titleMedium)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
if let description = document.description_, !description.isEmpty {
|
||||
Text(description)
|
||||
.font(AppTypography.bodySmall)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(getDocTypeDisplayName(document.documentType))
|
||||
.font(AppTypography.labelSmall)
|
||||
.foregroundColor(typeColor)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(typeColor.opacity(0.2))
|
||||
.cornerRadius(4)
|
||||
|
||||
if let fileSize = document.fileSize {
|
||||
Text(formatFileSize(Int(fileSize)))
|
||||
.font(AppTypography.labelSmall)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(AppColors.surface)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||
}
|
||||
|
||||
private func getDocTypeDisplayName(_ type: String) -> String {
|
||||
return DocumentType.companion.fromValue(value: type).displayName
|
||||
}
|
||||
|
||||
private func formatFileSize(_ bytes: Int) -> String {
|
||||
var size = Double(bytes)
|
||||
let units = ["B", "KB", "MB", "GB"]
|
||||
var unitIndex = 0
|
||||
|
||||
while size >= 1024 && unitIndex < units.count - 1 {
|
||||
size /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
|
||||
return String(format: "%.1f %@", size, units[unitIndex])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Types
|
||||
extension DocumentCategory: CaseIterable {
|
||||
public static var allCases: [DocumentCategory] {
|
||||
return [.appliance, .hvac, .plumbing, .electrical, .roofing, .structural, .other]
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
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 .other: return "Other"
|
||||
default: return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DocumentType: CaseIterable {
|
||||
public static var allCases: [DocumentType] {
|
||||
return [.warranty, .manual, .receipt, .inspection, .permit, .deed, .insurance, .contract, .photo, .other]
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .warranty: return "Warranty"
|
||||
case .manual: return "Manual"
|
||||
case .receipt: return "Receipt"
|
||||
case .inspection: return "Inspection"
|
||||
case .permit: return "Permit"
|
||||
case .deed: return "Deed"
|
||||
case .insurance: return "Insurance"
|
||||
case .contract: return "Contract"
|
||||
case .photo: return "Photo"
|
||||
case .other: return "Other"
|
||||
default: return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State View
|
||||
struct EmptyStateView: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 64))
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
|
||||
Text(title)
|
||||
.font(AppTypography.titleMedium)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
|
||||
Text(message)
|
||||
.font(AppTypography.bodyMedium)
|
||||
.foregroundColor(AppColors.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(AppSpacing.lg)
|
||||
}
|
||||
}
|
||||
@@ -31,12 +31,20 @@ struct MainTabView: View {
|
||||
.tag(2)
|
||||
|
||||
NavigationView {
|
||||
ProfileTabView()
|
||||
DocumentsWarrantiesView(residenceId: nil)
|
||||
}
|
||||
.tabItem {
|
||||
Label("Profile", systemImage: "person.fill")
|
||||
Label("Documents", systemImage: "doc.text.fill")
|
||||
}
|
||||
.tag(3)
|
||||
|
||||
// NavigationView {
|
||||
// ProfileTabView()
|
||||
// }
|
||||
// .tabItem {
|
||||
// Label("Profile", systemImage: "person.fill")
|
||||
// }
|
||||
// .tag(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user