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:
Trey t
2025-11-10 22:38:34 -06:00
parent d3caffa792
commit e716c919f3
11 changed files with 3047 additions and 5 deletions

View 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
}
}
}
}
}

View 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
}
}
}
}

View 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)
}
}

View File

@@ -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)
}
}
}