Refactor iOS and Android code to follow single responsibility principle

- iOS: Extracted views and helpers into separate files
  - Created Documents/Helpers/DocumentHelpers.swift for type/category helpers
  - Created Documents/Components/ with individual view files:
    - ImageViewerSheet.swift
    - WarrantyCard.swift
    - DocumentCard.swift
    - EmptyStateView.swift
    - WarrantiesTabContent.swift
    - DocumentsTabContent.swift
  - Cleaned up DocumentDetailView.swift and DocumentsWarrantiesView.swift

- Android: Extracted composables into organized component structure
  - Created ui/components/documents/ package with:
    - DocumentCard.kt (WarrantyCardContent, RegularDocumentCardContent, formatFileSize)
    - DocumentStates.kt (EmptyState, ErrorState)
    - DocumentsTabContent.kt
  - Reduced DocumentsScreen.kt from 506 lines to 211 lines
  - Added missing imports to DocumentDetailScreen.kt and EditDocumentScreen.kt

Net result: +770 insertions, -716 deletions across 15 files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-11 14:39:33 -06:00
parent 611f7d853b
commit 415799b6d0
15 changed files with 770 additions and 716 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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