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:
Trey t
2025-11-11 14:39:33 -06:00
parent 611f7d853b
commit 415799b6d0
15 changed files with 770 additions and 716 deletions

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

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

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

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

View File

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

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

View File

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

View File

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

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