Refactor iOS and Android code to follow single responsibility principle
- iOS: Extracted views and helpers into separate files
- Created Documents/Helpers/DocumentHelpers.swift for type/category helpers
- Created Documents/Components/ with individual view files:
- ImageViewerSheet.swift
- WarrantyCard.swift
- DocumentCard.swift
- EmptyStateView.swift
- WarrantiesTabContent.swift
- DocumentsTabContent.swift
- Cleaned up DocumentDetailView.swift and DocumentsWarrantiesView.swift
- Android: Extracted composables into organized component structure
- Created ui/components/documents/ package with:
- DocumentCard.kt (WarrantyCardContent, RegularDocumentCardContent, formatFileSize)
- DocumentStates.kt (EmptyState, ErrorState)
- DocumentsTabContent.kt
- Reduced DocumentsScreen.kt from 506 lines to 211 lines
- Added missing imports to DocumentDetailScreen.kt and EditDocumentScreen.kt
Net result: +770 insertions, -716 deletions across 15 files
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
99
iosApp/iosApp/Documents/Components/DocumentCard.swift
Normal file
99
iosApp/iosApp/Documents/Components/DocumentCard.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
50
iosApp/iosApp/Documents/Components/DocumentsTabContent.swift
Normal file
50
iosApp/iosApp/Documents/Components/DocumentsTabContent.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
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
|
||||
NavigationLink(destination: DocumentDetailView(documentId: document.id?.int32Value ?? 0)) {
|
||||
DocumentCard(document: document)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
iosApp/iosApp/Documents/Components/EmptyStateView.swift
Normal file
25
iosApp/iosApp/Documents/Components/EmptyStateView.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
import SwiftUI
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
58
iosApp/iosApp/Documents/Components/ImageViewerSheet.swift
Normal file
58
iosApp/iosApp/Documents/Components/ImageViewerSheet.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
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
|
||||
NavigationLink(destination: DocumentDetailView(documentId: warranty.id?.int32Value ?? 0)) {
|
||||
WarrantyCard(document: warranty)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
107
iosApp/iosApp/Documents/Components/WarrantyCard.swift
Normal file
107
iosApp/iosApp/Documents/Components/WarrantyCard.swift
Normal file
@@ -0,0 +1,107 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -435,94 +435,3 @@ struct DocumentDetailView: View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,310 +201,6 @@ struct DocumentsWarrantiesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
NavigationLink(destination: DocumentDetailView(documentId: warranty.id?.int32Value ?? 0)) {
|
||||
WarrantyCard(document: warranty)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.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
|
||||
NavigationLink(destination: DocumentDetailView(documentId: document.id?.int32Value ?? 0)) {
|
||||
DocumentCard(document: document)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.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] {
|
||||
@@ -546,28 +242,3 @@ extension DocumentType: CaseIterable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
34
iosApp/iosApp/Documents/Helpers/DocumentHelpers.swift
Normal file
34
iosApp/iosApp/Documents/Helpers/DocumentHelpers.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user