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,241 @@
package com.mycrib.android.ui.components.documents
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.mycrib.shared.models.Document
import com.mycrib.shared.models.DocumentCategory
import com.mycrib.shared.models.DocumentType
@Composable
fun DocumentCard(document: Document, isWarrantyCard: Boolean = false, onClick: () -> Unit) {
if (isWarrantyCard) {
WarrantyCardContent(document = document, onClick = onClick)
} else {
RegularDocumentCardContent(document = document, onClick = onClick)
}
}
@Composable
private fun WarrantyCardContent(document: Document, onClick: () -> Unit) {
val daysUntilExpiration = document.daysUntilExpiration ?: 0
val statusColor = when {
!document.isActive -> Color.Gray
daysUntilExpiration < 0 -> Color.Red
daysUntilExpiration < 30 -> Color(0xFFF59E0B)
daysUntilExpiration < 90 -> Color(0xFFFBBF24)
else -> Color(0xFF10B981)
}
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
shape = RoundedCornerShape(12.dp)
) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f)) {
Text(
document.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
document.itemName?.let { itemName ->
Text(
itemName,
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Box(
modifier = Modifier
.background(statusColor.copy(alpha = 0.2f), RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
when {
!document.isActive -> "Inactive"
daysUntilExpiration < 0 -> "Expired"
daysUntilExpiration < 30 -> "Expiring soon"
else -> "Active"
},
style = MaterialTheme.typography.labelSmall,
color = statusColor,
fontWeight = FontWeight.Bold
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text("Provider", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
Text(document.provider ?: "N/A", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
Column(horizontalAlignment = Alignment.End) {
Text("Expires", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
Text(document.endDate ?: "N/A", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
}
if (document.isActive && daysUntilExpiration >= 0) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"$daysUntilExpiration days remaining",
style = MaterialTheme.typography.labelMedium,
color = statusColor
)
}
document.category?.let { category ->
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.background(Color(0xFFE5E7EB), RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
DocumentCategory.fromValue(category).displayName,
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF374151)
)
}
}
}
}
}
@Composable
private fun RegularDocumentCardContent(document: Document, onClick: () -> Unit) {
val typeColor = when (document.documentType) {
"warranty" -> Color(0xFF3B82F6)
"manual" -> Color(0xFF8B5CF6)
"receipt" -> Color(0xFF10B981)
"inspection" -> Color(0xFFF59E0B)
else -> Color(0xFF6B7280)
}
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Document icon
Box(
modifier = Modifier
.size(56.dp)
.background(typeColor.copy(alpha = 0.1f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
Icon(
when (document.documentType) {
"photo" -> Icons.Default.Image
"warranty", "insurance" -> Icons.Default.VerifiedUser
"manual" -> Icons.Default.MenuBook
"receipt" -> Icons.Default.Receipt
else -> Icons.Default.Description
},
contentDescription = null,
tint = typeColor,
modifier = Modifier.size(32.dp)
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
document.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
if (document.description?.isNotBlank() == true) {
Text(
document.description,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.background(typeColor.copy(alpha = 0.2f), RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(
DocumentType.fromValue(document.documentType).displayName,
style = MaterialTheme.typography.labelSmall,
color = typeColor
)
}
document.fileSize?.let { size ->
Text(
formatFileSize(size),
style = MaterialTheme.typography.labelSmall,
color = Color.Gray
)
}
}
}
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
tint = Color.Gray
)
}
}
}
fun formatFileSize(bytes: Int): String {
var size = bytes.toDouble()
val units = listOf("B", "KB", "MB", "GB")
var unitIndex = 0
while (size >= 1024 && unitIndex < units.size - 1) {
size /= 1024
unitIndex++
}
return "%.1f %s".format(size, units[unitIndex])
}

View File

@@ -0,0 +1,42 @@
package com.mycrib.android.ui.components.documents
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun EmptyState(icon: ImageVector, message: String) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(64.dp), tint = Color.Gray)
Spacer(modifier = Modifier.height(16.dp))
Text(message, style = MaterialTheme.typography.titleMedium, color = Color.Gray)
}
}
@Composable
fun ErrorState(message: String, onRetry: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(Icons.Default.Error, contentDescription = null, modifier = Modifier.size(64.dp), tint = Color.Red)
Spacer(modifier = Modifier.height(16.dp))
Text(message, style = MaterialTheme.typography.bodyLarge, color = Color.Gray)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onRetry) {
Text("Retry")
}
}
}

View File

@@ -0,0 +1,58 @@
package com.mycrib.android.ui.components.documents
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.ReceiptLong
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.mycrib.shared.models.DocumentListResponse
import com.mycrib.shared.network.ApiResult
@Composable
fun DocumentsTabContent(
state: ApiResult<DocumentListResponse>,
isWarrantyTab: Boolean,
onDocumentClick: (Int) -> Unit,
onRetry: () -> Unit
) {
when (state) {
is ApiResult.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is ApiResult.Success -> {
val documents = state.data.results
if (documents.isEmpty()) {
EmptyState(
icon = if (isWarrantyTab) Icons.Default.ReceiptLong else Icons.Default.Description,
message = if (isWarrantyTab) "No warranties found" else "No documents found"
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(documents) { document ->
DocumentCard(
document = document,
isWarrantyCard = isWarrantyTab,
onClick = { document.id?.let { onDocumentClick(it) } }
)
}
}
}
}
is ApiResult.Error -> {
ErrorState(message = state.message, onRetry = onRetry)
}
is ApiResult.Idle -> {}
}
}

View File

@@ -23,6 +23,8 @@ import androidx.compose.foundation.Image
import coil3.compose.AsyncImage
import coil3.compose.rememberAsyncImagePainter
import androidx.compose.ui.window.Dialog
import com.mycrib.android.ui.components.documents.ErrorState
import com.mycrib.android.ui.components.documents.formatFileSize
import androidx.compose.ui.window.DialogProperties
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid

View File

@@ -1,28 +1,18 @@
package com.mycrib.android.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.mycrib.android.ui.components.documents.DocumentsTabContent
import com.mycrib.android.viewmodel.DocumentViewModel
import com.mycrib.shared.models.*
import com.mycrib.shared.network.ApiResult
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
enum class DocumentTab {
WARRANTIES, DOCUMENTS
@@ -219,288 +209,3 @@ fun DocumentsScreen(
}
}
}
@Composable
fun DocumentsTabContent(
state: ApiResult<DocumentListResponse>,
isWarrantyTab: Boolean,
onDocumentClick: (Int) -> Unit,
onRetry: () -> Unit
) {
when (state) {
is ApiResult.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is ApiResult.Success -> {
val documents = state.data.results
if (documents.isEmpty()) {
EmptyState(
icon = if (isWarrantyTab) Icons.Default.ReceiptLong else Icons.Default.Description,
message = if (isWarrantyTab) "No warranties found" else "No documents found"
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(documents) { document ->
DocumentCard(
document = document,
isWarrantyCard = isWarrantyTab,
onClick = { document.id?.let { onDocumentClick(it) } }
)
}
}
}
}
is ApiResult.Error -> {
ErrorState(message = state.message, onRetry = onRetry)
}
is ApiResult.Idle -> {}
}
}
@Composable
fun DocumentCard(document: Document, isWarrantyCard: Boolean = false, onClick: () -> Unit) {
if (isWarrantyCard) {
// Warranty-specific card layout
val daysUntilExpiration = document.daysUntilExpiration ?: 0
val statusColor = when {
!document.isActive -> Color.Gray
daysUntilExpiration < 0 -> Color.Red
daysUntilExpiration < 30 -> Color(0xFFF59E0B)
daysUntilExpiration < 90 -> Color(0xFFFBBF24)
else -> Color(0xFF10B981)
}
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
shape = RoundedCornerShape(12.dp)
) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f)) {
Text(
document.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
document.itemName?.let { itemName ->
Text(
itemName,
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Box(
modifier = Modifier
.background(statusColor.copy(alpha = 0.2f), RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
when {
!document.isActive -> "Inactive"
daysUntilExpiration < 0 -> "Expired"
daysUntilExpiration < 30 -> "Expiring soon"
else -> "Active"
},
style = MaterialTheme.typography.labelSmall,
color = statusColor,
fontWeight = FontWeight.Bold
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text("Provider", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
Text(document.provider ?: "N/A", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
Column(horizontalAlignment = Alignment.End) {
Text("Expires", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
Text(document.endDate ?: "N/A", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
}
if (document.isActive && daysUntilExpiration >= 0) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"$daysUntilExpiration days remaining",
style = MaterialTheme.typography.labelMedium,
color = statusColor
)
}
document.category?.let { category ->
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.background(Color(0xFFE5E7EB), RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
DocumentCategory.fromValue(category).displayName,
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF374151)
)
}
}
}
}
} else {
// Regular document card layout
val typeColor = when (document.documentType) {
"warranty" -> Color(0xFF3B82F6)
"manual" -> Color(0xFF8B5CF6)
"receipt" -> Color(0xFF10B981)
"inspection" -> Color(0xFFF59E0B)
else -> Color(0xFF6B7280)
}
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Document icon
Box(
modifier = Modifier
.size(56.dp)
.background(typeColor.copy(alpha = 0.1f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
Icon(
when (document.documentType) {
"photo" -> Icons.Default.Image
"warranty", "insurance" -> Icons.Default.VerifiedUser
"manual" -> Icons.Default.MenuBook
"receipt" -> Icons.Default.Receipt
else -> Icons.Default.Description
},
contentDescription = null,
tint = typeColor,
modifier = Modifier.size(32.dp)
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
document.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
if (document.description?.isNotBlank() == true) {
Text(
document.description,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.background(typeColor.copy(alpha = 0.2f), RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(
DocumentType.fromValue(document.documentType).displayName,
style = MaterialTheme.typography.labelSmall,
color = typeColor
)
}
document.fileSize?.let { size ->
Text(
formatFileSize(size),
style = MaterialTheme.typography.labelSmall,
color = Color.Gray
)
}
}
}
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
tint = Color.Gray
)
}
}
}
}
@Composable
fun EmptyState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(64.dp), tint = Color.Gray)
Spacer(modifier = Modifier.height(16.dp))
Text(message, style = MaterialTheme.typography.titleMedium, color = Color.Gray)
}
}
@Composable
fun ErrorState(message: String, onRetry: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(Icons.Default.Error, contentDescription = null, modifier = Modifier.size(64.dp), tint = Color.Red)
Spacer(modifier = Modifier.height(16.dp))
Text(message, style = MaterialTheme.typography.bodyLarge, color = Color.Gray)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onRetry) {
Text("Retry")
}
}
}
fun formatFileSize(bytes: Int): String {
var size = bytes.toDouble()
for (unit in listOf("B", "KB", "MB", "GB")) {
if (size < 1024.0) {
return "${(size * 10).toInt() / 10.0} $unit"
}
size /= 1024.0
}
return "${(size * 10).toInt() / 10.0} TB"
}

View File

@@ -19,6 +19,7 @@ import com.mycrib.shared.network.ApiResult
import com.mycrib.platform.ImageData
import com.mycrib.platform.rememberImagePicker
import com.mycrib.platform.rememberCameraPicker
import com.mycrib.android.ui.components.documents.ErrorState
@OptIn(ExperimentalMaterial3Api::class)
@Composable

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