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:
@@ -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])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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