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.AsyncImage
|
||||||
import coil3.compose.rememberAsyncImagePainter
|
import coil3.compose.rememberAsyncImagePainter
|
||||||
import androidx.compose.ui.window.Dialog
|
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.ui.window.DialogProperties
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
|||||||
@@ -1,28 +1,18 @@
|
|||||||
package com.mycrib.android.ui.screens
|
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.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.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.mycrib.android.ui.components.documents.DocumentsTabContent
|
||||||
import com.mycrib.android.viewmodel.DocumentViewModel
|
import com.mycrib.android.viewmodel.DocumentViewModel
|
||||||
import com.mycrib.shared.models.*
|
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 {
|
enum class DocumentTab {
|
||||||
WARRANTIES, DOCUMENTS
|
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.ImageData
|
||||||
import com.mycrib.platform.rememberImagePicker
|
import com.mycrib.platform.rememberImagePicker
|
||||||
import com.mycrib.platform.rememberCameraPicker
|
import com.mycrib.platform.rememberCameraPicker
|
||||||
|
import com.mycrib.android.ui.components.documents.ErrorState
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@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))
|
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
|
// MARK: - Supporting Types
|
||||||
extension DocumentCategory: CaseIterable {
|
extension DocumentCategory: CaseIterable {
|
||||||
public static var allCases: [DocumentCategory] {
|
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