Add document viewing, editing, and image deletion features
- Add DocumentDetailScreen and EditDocumentScreen for Compose (Android/Web) - Add DocumentDetailView and EditDocumentView for iOS SwiftUI - Add DocumentViewModelWrapper for iOS state management - Implement document image deletion API integration - Fix iOS navigation issues with edit button using hidden NavigationLink - Add clickable warranties in iOS with NavigationLink - Fix iOS build errors with proper type checking and state handling - Add support for viewing and managing warranty-specific fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,14 @@ package com.mycrib.shared.models
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class DocumentImage(
|
||||
val id: Int? = null,
|
||||
@SerialName("image_url") val imageUrl: String,
|
||||
val caption: String? = null,
|
||||
@SerialName("uploaded_at") val uploadedAt: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Document(
|
||||
val id: Int? = null,
|
||||
@@ -33,6 +41,8 @@ data class Document(
|
||||
@SerialName("contractor_phone") val contractorPhone: String? = null,
|
||||
@SerialName("uploaded_by") val uploadedBy: Int? = null,
|
||||
@SerialName("uploaded_by_username") val uploadedByUsername: String? = null,
|
||||
// Images
|
||||
val images: List<DocumentImage> = emptyList(),
|
||||
// Metadata
|
||||
val tags: String? = null,
|
||||
val notes: String? = null,
|
||||
|
||||
@@ -101,6 +101,12 @@ data class AddDocumentRoute(
|
||||
val initialDocumentType: String = "other"
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DocumentDetailRoute(val documentId: Int)
|
||||
|
||||
@Serializable
|
||||
data class EditDocumentRoute(val documentId: Int)
|
||||
|
||||
@Serializable
|
||||
object ForgotPasswordRoute
|
||||
|
||||
|
||||
@@ -360,4 +360,20 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteDocumentImage(token: String, imageId: Int): ApiResult<Unit> {
|
||||
return try {
|
||||
val response = client.delete("$baseUrl/document-images/$imageId/") {
|
||||
header("Authorization", "Token $token")
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(Unit)
|
||||
} else {
|
||||
ApiResult.Error("Failed to delete image", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mycrib.android.viewmodel.DocumentViewModel
|
||||
import com.mycrib.shared.models.*
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
import androidx.compose.foundation.Image
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.rememberAsyncImagePainter
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import coil3.compose.SubcomposeAsyncImageContent
|
||||
import coil3.compose.AsyncImagePainter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DocumentDetailScreen(
|
||||
documentId: Int,
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToEdit: (Int) -> Unit,
|
||||
documentViewModel: DocumentViewModel = viewModel { DocumentViewModel() }
|
||||
) {
|
||||
val documentState by documentViewModel.documentDetailState.collectAsState()
|
||||
val deleteState by documentViewModel.deleteState.collectAsState()
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var showPhotoViewer by remember { mutableStateOf(false) }
|
||||
var selectedPhotoIndex by remember { mutableStateOf(0) }
|
||||
|
||||
LaunchedEffect(documentId) {
|
||||
documentViewModel.loadDocumentDetail(documentId)
|
||||
}
|
||||
|
||||
// Handle successful deletion
|
||||
LaunchedEffect(deleteState) {
|
||||
if (deleteState is ApiResult.Success) {
|
||||
documentViewModel.resetDeleteState()
|
||||
onNavigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Document Details", fontWeight = FontWeight.Bold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
when (documentState) {
|
||||
is ApiResult.Success -> {
|
||||
IconButton(onClick = { onNavigateToEdit(documentId) }) {
|
||||
Icon(Icons.Default.Edit, "Edit")
|
||||
}
|
||||
IconButton(onClick = { showDeleteDialog = true }) {
|
||||
Icon(Icons.Default.Delete, "Delete", tint = Color.Red)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
when (val state = documentState) {
|
||||
is ApiResult.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
is ApiResult.Success -> {
|
||||
val document = state.data
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Status badge (for warranties)
|
||||
if (document.documentType == "warranty") {
|
||||
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(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = statusColor.copy(alpha = 0.1f)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"Status",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.Gray
|
||||
)
|
||||
Text(
|
||||
when {
|
||||
!document.isActive -> "Inactive"
|
||||
daysUntilExpiration < 0 -> "Expired"
|
||||
daysUntilExpiration < 30 -> "Expiring soon"
|
||||
else -> "Active"
|
||||
},
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
if (document.isActive && daysUntilExpiration >= 0) {
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
"Days Remaining",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.Gray
|
||||
)
|
||||
Text(
|
||||
"$daysUntilExpiration",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Basic Information
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Basic Information",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Divider()
|
||||
|
||||
DetailRow("Title", document.title)
|
||||
DetailRow("Type", DocumentType.fromValue(document.documentType).displayName)
|
||||
document.category?.let {
|
||||
DetailRow("Category", DocumentCategory.fromValue(it).displayName)
|
||||
}
|
||||
document.description?.let {
|
||||
DetailRow("Description", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Warranty/Item Details (for warranties)
|
||||
if (document.documentType == "warranty" &&
|
||||
(document.itemName != null || document.modelNumber != null ||
|
||||
document.serialNumber != null || document.provider != null)) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Item Details",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Divider()
|
||||
|
||||
document.itemName?.let { DetailRow("Item Name", it) }
|
||||
document.modelNumber?.let { DetailRow("Model Number", it) }
|
||||
document.serialNumber?.let { DetailRow("Serial Number", it) }
|
||||
document.provider?.let { DetailRow("Provider", it) }
|
||||
document.providerContact?.let { DetailRow("Provider Contact", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Claim Information (for warranties)
|
||||
if (document.documentType == "warranty" &&
|
||||
(document.claimPhone != null || document.claimEmail != null ||
|
||||
document.claimWebsite != null)) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Claim Information",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Divider()
|
||||
|
||||
document.claimPhone?.let { DetailRow("Claim Phone", it) }
|
||||
document.claimEmail?.let { DetailRow("Claim Email", it) }
|
||||
document.claimWebsite?.let { DetailRow("Claim Website", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dates
|
||||
if (document.purchaseDate != null || document.startDate != null ||
|
||||
document.endDate != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Important Dates",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Divider()
|
||||
|
||||
document.purchaseDate?.let { DetailRow("Purchase Date", it) }
|
||||
document.startDate?.let { DetailRow("Start Date", it) }
|
||||
document.endDate?.let { DetailRow("End Date", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Residence & Contractor
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Associations",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Divider()
|
||||
|
||||
document.residenceAddress?.let { DetailRow("Residence", it) }
|
||||
document.contractorName?.let { DetailRow("Contractor", it) }
|
||||
document.contractorPhone?.let { DetailRow("Contractor Phone", it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Additional Information
|
||||
if (document.tags != null || document.notes != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Additional Information",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Divider()
|
||||
|
||||
document.tags?.let { DetailRow("Tags", it) }
|
||||
document.notes?.let { DetailRow("Notes", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Images
|
||||
if (document.images.isNotEmpty()) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Images (${document.images.size})",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Divider()
|
||||
|
||||
// Image grid
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
document.images.take(4).forEachIndexed { index, image ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(1f)
|
||||
.clickable {
|
||||
selectedPhotoIndex = index
|
||||
showPhotoViewer = true
|
||||
}
|
||||
) {
|
||||
AsyncImage(
|
||||
model = image.imageUrl,
|
||||
contentDescription = image.caption,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop
|
||||
)
|
||||
if (index == 3 && document.images.size > 4) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.6f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"+${document.images.size - 4}",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// File Information
|
||||
if (document.fileUrl != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Attached File",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Divider()
|
||||
|
||||
document.fileType?.let { DetailRow("File Type", it) }
|
||||
document.fileSize?.let {
|
||||
DetailRow("File Size", formatFileSize(it))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { /* TODO: Download file */ },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.Download, null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Download File")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Metadata",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Divider()
|
||||
|
||||
document.uploadedByUsername?.let { DetailRow("Uploaded By", it) }
|
||||
document.createdAt?.let { DetailRow("Created", it) }
|
||||
document.updatedAt?.let { DetailRow("Updated", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
ErrorState(
|
||||
message = state.message,
|
||||
onRetry = { documentViewModel.loadDocumentDetail(documentId) }
|
||||
)
|
||||
}
|
||||
is ApiResult.Idle -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation dialog
|
||||
if (showDeleteDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("Delete Document") },
|
||||
text = { Text("Are you sure you want to delete this document? This action cannot be undone.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
documentViewModel.deleteDocument(documentId)
|
||||
showDeleteDialog = false
|
||||
}
|
||||
) {
|
||||
Text("Delete", color = Color.Red)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Photo viewer dialog
|
||||
if (showPhotoViewer && documentState is ApiResult.Success) {
|
||||
val document = (documentState as ApiResult.Success<Document>).data
|
||||
if (document.images.isNotEmpty()) {
|
||||
DocumentImageViewer(
|
||||
images = document.images,
|
||||
initialIndex = selectedPhotoIndex,
|
||||
onDismiss = { showPhotoViewer = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DetailRow(label: String, value: String) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.Gray
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DocumentImageViewer(
|
||||
images: List<DocumentImage>,
|
||||
initialIndex: Int = 0,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var selectedIndex by remember { mutableStateOf(initialIndex) }
|
||||
var showFullImage by remember { mutableStateOf(false) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = {
|
||||
if (showFullImage) {
|
||||
showFullImage = false
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true,
|
||||
usePlatformDefaultWidth = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.95f)
|
||||
.fillMaxHeight(0.9f),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (showFullImage) "Image ${selectedIndex + 1} of ${images.size}" else "Document Images",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
IconButton(onClick = {
|
||||
if (showFullImage) {
|
||||
showFullImage = false
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Close"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Content
|
||||
if (showFullImage) {
|
||||
// Single image view
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
SubcomposeAsyncImage(
|
||||
model = images[selectedIndex].imageUrl,
|
||||
contentDescription = images[selectedIndex].caption,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Fit
|
||||
) {
|
||||
val state = painter.state
|
||||
when (state) {
|
||||
is AsyncImagePainter.State.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
is AsyncImagePainter.State.Error -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.BrokenImage,
|
||||
contentDescription = "Error loading image",
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
"Failed to load image",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> SubcomposeAsyncImageContent()
|
||||
}
|
||||
}
|
||||
|
||||
images[selectedIndex].caption?.let { caption ->
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = caption,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation buttons
|
||||
if (images.size > 1) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Button(
|
||||
onClick = { selectedIndex = (selectedIndex - 1 + images.size) % images.size },
|
||||
enabled = selectedIndex > 0
|
||||
) {
|
||||
Icon(Icons.Default.ArrowBack, "Previous")
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Previous")
|
||||
}
|
||||
Button(
|
||||
onClick = { selectedIndex = (selectedIndex + 1) % images.size },
|
||||
enabled = selectedIndex < images.size - 1
|
||||
) {
|
||||
Text("Next")
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(Icons.Default.ArrowForward, "Next")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Grid view
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(images.size) { index ->
|
||||
val image = images[index]
|
||||
Card(
|
||||
onClick = {
|
||||
selectedIndex = index
|
||||
showFullImage = true
|
||||
},
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column {
|
||||
SubcomposeAsyncImage(
|
||||
model = image.imageUrl,
|
||||
contentDescription = image.caption,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop
|
||||
) {
|
||||
val state = painter.state
|
||||
when (state) {
|
||||
is AsyncImagePainter.State.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
is AsyncImagePainter.State.Error -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.errorContainer),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.BrokenImage,
|
||||
contentDescription = "Error",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> SubcomposeAsyncImageContent()
|
||||
}
|
||||
}
|
||||
|
||||
image.caption?.let { caption ->
|
||||
Text(
|
||||
text = caption,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ fun DocumentsScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
residenceId: Int? = null,
|
||||
onNavigateToAddDocument: (residenceId: Int, documentType: String) -> Unit = { _, _ -> },
|
||||
onNavigateToDocumentDetail: (documentId: Int) -> Unit = {},
|
||||
documentViewModel: DocumentViewModel = viewModel { DocumentViewModel() }
|
||||
) {
|
||||
var selectedTab by remember { mutableStateOf(DocumentTab.WARRANTIES) }
|
||||
@@ -190,6 +191,7 @@ fun DocumentsScreen(
|
||||
DocumentsTabContent(
|
||||
state = documentsState,
|
||||
isWarrantyTab = true,
|
||||
onDocumentClick = onNavigateToDocumentDetail,
|
||||
onRetry = {
|
||||
documentViewModel.loadDocuments(
|
||||
residenceId = residenceId,
|
||||
@@ -204,6 +206,7 @@ fun DocumentsScreen(
|
||||
DocumentsTabContent(
|
||||
state = documentsState,
|
||||
isWarrantyTab = false,
|
||||
onDocumentClick = onNavigateToDocumentDetail,
|
||||
onRetry = {
|
||||
documentViewModel.loadDocuments(
|
||||
residenceId = residenceId,
|
||||
@@ -221,6 +224,7 @@ fun DocumentsScreen(
|
||||
fun DocumentsTabContent(
|
||||
state: ApiResult<DocumentListResponse>,
|
||||
isWarrantyTab: Boolean,
|
||||
onDocumentClick: (Int) -> Unit,
|
||||
onRetry: () -> Unit
|
||||
) {
|
||||
when (state) {
|
||||
@@ -243,7 +247,11 @@ fun DocumentsTabContent(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(documents) { document ->
|
||||
DocumentCard(document = document, isWarrantyCard = isWarrantyTab, onClick = { /* TODO */ })
|
||||
DocumentCard(
|
||||
document = document,
|
||||
isWarrantyCard = isWarrantyTab,
|
||||
onClick = { document.id?.let { onDocumentClick(it) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
package com.mycrib.android.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mycrib.android.viewmodel.DocumentViewModel
|
||||
import com.mycrib.shared.models.*
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
import com.mycrib.platform.ImageData
|
||||
import com.mycrib.platform.rememberImagePicker
|
||||
import com.mycrib.platform.rememberCameraPicker
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditDocumentScreen(
|
||||
documentId: Int,
|
||||
onNavigateBack: () -> Unit,
|
||||
documentViewModel: DocumentViewModel = viewModel { DocumentViewModel() }
|
||||
) {
|
||||
val documentDetailState by documentViewModel.documentDetailState.collectAsState()
|
||||
val updateState by documentViewModel.updateState.collectAsState()
|
||||
|
||||
// Form state
|
||||
var title by remember { mutableStateOf("") }
|
||||
var documentType by remember { mutableStateOf("other") }
|
||||
var category by remember { mutableStateOf<String?>(null) }
|
||||
var description by remember { mutableStateOf("") }
|
||||
var tags by remember { mutableStateOf("") }
|
||||
var notes by remember { mutableStateOf("") }
|
||||
var isActive by remember { mutableStateOf(true) }
|
||||
|
||||
// Warranty-specific fields
|
||||
var itemName by remember { mutableStateOf("") }
|
||||
var modelNumber by remember { mutableStateOf("") }
|
||||
var serialNumber by remember { mutableStateOf("") }
|
||||
var provider by remember { mutableStateOf("") }
|
||||
var providerContact by remember { mutableStateOf("") }
|
||||
var claimPhone by remember { mutableStateOf("") }
|
||||
var claimEmail by remember { mutableStateOf("") }
|
||||
var claimWebsite by remember { mutableStateOf("") }
|
||||
var purchaseDate by remember { mutableStateOf("") }
|
||||
var startDate by remember { mutableStateOf("") }
|
||||
var endDate by remember { mutableStateOf("") }
|
||||
|
||||
var showCategoryMenu by remember { mutableStateOf(false) }
|
||||
var showTypeMenu by remember { mutableStateOf(false) }
|
||||
var showSnackbar by remember { mutableStateOf(false) }
|
||||
var snackbarMessage by remember { mutableStateOf("") }
|
||||
|
||||
// Image management
|
||||
var existingImages by remember { mutableStateOf<List<DocumentImage>>(emptyList()) }
|
||||
var newImages by remember { mutableStateOf<List<ImageData>>(emptyList()) }
|
||||
var imagesToDelete by remember { mutableStateOf<Set<Int>>(emptySet()) }
|
||||
|
||||
val imagePicker = rememberImagePicker { images ->
|
||||
// Limit total images to 10
|
||||
val totalCount = existingImages.size - imagesToDelete.size + newImages.size
|
||||
newImages = if (totalCount + images.size <= 10) {
|
||||
newImages + images
|
||||
} else {
|
||||
newImages + images.take(10 - totalCount)
|
||||
}
|
||||
}
|
||||
|
||||
val cameraPicker = rememberCameraPicker { image ->
|
||||
val totalCount = existingImages.size - imagesToDelete.size + newImages.size
|
||||
if (totalCount < 10) {
|
||||
newImages = newImages + image
|
||||
}
|
||||
}
|
||||
|
||||
// Load document details on first composition
|
||||
LaunchedEffect(documentId) {
|
||||
documentViewModel.loadDocumentDetail(documentId)
|
||||
}
|
||||
|
||||
// Populate form when document loads
|
||||
LaunchedEffect(documentDetailState) {
|
||||
if (documentDetailState is ApiResult.Success) {
|
||||
val doc = (documentDetailState as ApiResult.Success<Document>).data
|
||||
title = doc.title
|
||||
documentType = doc.documentType
|
||||
category = doc.category
|
||||
description = doc.description ?: ""
|
||||
tags = doc.tags ?: ""
|
||||
notes = doc.notes ?: ""
|
||||
isActive = doc.isActive
|
||||
|
||||
// Warranty fields
|
||||
itemName = doc.itemName ?: ""
|
||||
modelNumber = doc.modelNumber ?: ""
|
||||
serialNumber = doc.serialNumber ?: ""
|
||||
provider = doc.provider ?: ""
|
||||
providerContact = doc.providerContact ?: ""
|
||||
claimPhone = doc.claimPhone ?: ""
|
||||
claimEmail = doc.claimEmail ?: ""
|
||||
claimWebsite = doc.claimWebsite ?: ""
|
||||
purchaseDate = doc.purchaseDate ?: ""
|
||||
startDate = doc.startDate ?: ""
|
||||
endDate = doc.endDate ?: ""
|
||||
|
||||
// Load existing images
|
||||
existingImages = doc.images
|
||||
}
|
||||
}
|
||||
|
||||
// Handle update result
|
||||
LaunchedEffect(updateState) {
|
||||
when (updateState) {
|
||||
is ApiResult.Success -> {
|
||||
snackbarMessage = "Document updated successfully"
|
||||
showSnackbar = true
|
||||
documentViewModel.resetUpdateState()
|
||||
// Refresh document details
|
||||
documentViewModel.loadDocumentDetail(documentId)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
snackbarMessage = (updateState as ApiResult.Error).message
|
||||
showSnackbar = true
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Edit Document", fontWeight = FontWeight.Bold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
if (showSnackbar) {
|
||||
Snackbar(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
action = {
|
||||
TextButton(onClick = { showSnackbar = false }) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(snackbarMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
when (documentDetailState) {
|
||||
is ApiResult.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
is ApiResult.Success -> {
|
||||
val document = (documentDetailState as ApiResult.Success<Document>).data
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Document Type (Read-only for editing)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
"Document Type",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
DocumentType.fromValue(documentType).displayName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
"Document type cannot be changed",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Basic Information
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Basic Information",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
label = { Text("Title *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Category (only for warranties)
|
||||
if (documentType == "warranty") {
|
||||
Box {
|
||||
OutlinedTextField(
|
||||
value = category?.let { DocumentCategory.fromValue(it).displayName } ?: "Select category",
|
||||
onValueChange = {},
|
||||
label = { Text("Category") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
readOnly = true,
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showCategoryMenu = true }) {
|
||||
Icon(Icons.Default.ArrowDropDown, "Select")
|
||||
}
|
||||
}
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = showCategoryMenu,
|
||||
onDismissRequest = { showCategoryMenu = false }
|
||||
) {
|
||||
DocumentCategory.values().forEach { cat ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(cat.displayName) },
|
||||
onClick = {
|
||||
category = cat.value
|
||||
showCategoryMenu = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Description") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Warranty/Item Details (only for warranties)
|
||||
if (documentType == "warranty") {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Item Details",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = itemName,
|
||||
onValueChange = { itemName = it },
|
||||
label = { Text("Item Name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = modelNumber,
|
||||
onValueChange = { modelNumber = it },
|
||||
label = { Text("Model Number") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = serialNumber,
|
||||
onValueChange = { serialNumber = it },
|
||||
label = { Text("Serial Number") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = provider,
|
||||
onValueChange = { provider = it },
|
||||
label = { Text("Provider/Manufacturer") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = providerContact,
|
||||
onValueChange = { providerContact = it },
|
||||
label = { Text("Provider Contact") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Claim Information
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Claim Information",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = claimPhone,
|
||||
onValueChange = { claimPhone = it },
|
||||
label = { Text("Claim Phone") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = claimEmail,
|
||||
onValueChange = { claimEmail = it },
|
||||
label = { Text("Claim Email") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = claimWebsite,
|
||||
onValueChange = { claimWebsite = it },
|
||||
label = { Text("Claim Website") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Dates
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Important Dates",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = purchaseDate,
|
||||
onValueChange = { purchaseDate = it },
|
||||
label = { Text("Purchase Date (YYYY-MM-DD)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = startDate,
|
||||
onValueChange = { startDate = it },
|
||||
label = { Text("Start Date (YYYY-MM-DD)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = endDate,
|
||||
onValueChange = { endDate = it },
|
||||
label = { Text("End Date (YYYY-MM-DD)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Image Management
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
val totalImages = existingImages.size - imagesToDelete.size + newImages.size
|
||||
Text(
|
||||
"Photos ($totalImages/10)",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { cameraPicker() },
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = totalImages < 10
|
||||
) {
|
||||
Icon(Icons.Default.CameraAlt, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Camera")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { imagePicker() },
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = totalImages < 10
|
||||
) {
|
||||
Icon(Icons.Default.Photo, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Gallery")
|
||||
}
|
||||
}
|
||||
|
||||
// Display existing images
|
||||
if (existingImages.isNotEmpty()) {
|
||||
Text(
|
||||
"Existing Images",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
existingImages.forEach { image ->
|
||||
if (image.id !in imagesToDelete) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Image,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
image.caption ?: "Image ${image.id}",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
image.id?.let {
|
||||
imagesToDelete = imagesToDelete + it
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove image",
|
||||
tint = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Display new images to be uploaded
|
||||
if (newImages.isNotEmpty()) {
|
||||
Text(
|
||||
"New Images",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
newImages.forEachIndexed { index, image ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Image,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
"New Image ${index + 1}",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
newImages = newImages.filter { it != image }
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove image",
|
||||
tint = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional Information
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Additional Information",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = tags,
|
||||
onValueChange = { tags = it },
|
||||
label = { Text("Tags (comma-separated)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
label = { Text("Notes") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Active", style = MaterialTheme.typography.bodyLarge)
|
||||
Switch(
|
||||
checked = isActive,
|
||||
onCheckedChange = { isActive = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save Button
|
||||
Button(
|
||||
onClick = {
|
||||
if (title.isNotBlank()) {
|
||||
// First, delete any images marked for deletion
|
||||
imagesToDelete.forEach { imageId ->
|
||||
documentViewModel.deleteDocumentImage(imageId)
|
||||
}
|
||||
|
||||
// Then update the document with new images
|
||||
documentViewModel.updateDocument(
|
||||
id = documentId,
|
||||
title = title,
|
||||
documentType = documentType,
|
||||
description = description.ifBlank { null },
|
||||
category = category,
|
||||
tags = tags.ifBlank { null },
|
||||
notes = notes.ifBlank { null },
|
||||
isActive = isActive,
|
||||
itemName = itemName.ifBlank { null },
|
||||
modelNumber = modelNumber.ifBlank { null },
|
||||
serialNumber = serialNumber.ifBlank { null },
|
||||
provider = provider.ifBlank { null },
|
||||
providerContact = providerContact.ifBlank { null },
|
||||
claimPhone = claimPhone.ifBlank { null },
|
||||
claimEmail = claimEmail.ifBlank { null },
|
||||
claimWebsite = claimWebsite.ifBlank { null },
|
||||
purchaseDate = purchaseDate.ifBlank { null },
|
||||
startDate = startDate.ifBlank { null },
|
||||
endDate = endDate.ifBlank { null },
|
||||
images = newImages
|
||||
)
|
||||
} else {
|
||||
snackbarMessage = "Title is required"
|
||||
showSnackbar = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = updateState !is ApiResult.Loading
|
||||
) {
|
||||
if (updateState is ApiResult.Loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = Color.White
|
||||
)
|
||||
} else {
|
||||
Text("Save Changes")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
ErrorState(
|
||||
message = (documentDetailState as ApiResult.Error).message,
|
||||
onRetry = { documentViewModel.loadDocumentDetail(documentId) }
|
||||
)
|
||||
}
|
||||
is ApiResult.Idle -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,9 @@ fun MainScreen(
|
||||
initialDocumentType = documentType
|
||||
)
|
||||
)
|
||||
},
|
||||
onNavigateToDocumentDetail = { documentId ->
|
||||
navController.navigate(DocumentDetailRoute(documentId))
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -213,6 +216,25 @@ fun MainScreen(
|
||||
)
|
||||
}
|
||||
|
||||
composable<DocumentDetailRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<DocumentDetailRoute>()
|
||||
DocumentDetailScreen(
|
||||
documentId = route.documentId,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToEdit = { documentId ->
|
||||
navController.navigate(EditDocumentRoute(documentId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<EditDocumentRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<EditDocumentRoute>()
|
||||
EditDocumentScreen(
|
||||
documentId = route.documentId,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable<MainTabProfileRoute> {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ProfileScreen(
|
||||
|
||||
@@ -31,6 +31,9 @@ class DocumentViewModel : ViewModel() {
|
||||
private val _downloadState = MutableStateFlow<ApiResult<ByteArray>>(ApiResult.Idle)
|
||||
val downloadState: StateFlow<ApiResult<ByteArray>> = _downloadState
|
||||
|
||||
private val _deleteImageState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
||||
val deleteImageState: StateFlow<ApiResult<Unit>> = _deleteImageState
|
||||
|
||||
fun loadDocuments(
|
||||
residenceId: Int? = null,
|
||||
documentType: String? = null,
|
||||
@@ -151,6 +154,66 @@ class DocumentViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDocument(
|
||||
id: Int,
|
||||
title: String,
|
||||
documentType: String,
|
||||
description: String? = null,
|
||||
category: String? = null,
|
||||
tags: String? = null,
|
||||
notes: String? = null,
|
||||
contractorId: Int? = null,
|
||||
isActive: Boolean = true,
|
||||
// Warranty-specific fields
|
||||
itemName: String? = null,
|
||||
modelNumber: String? = null,
|
||||
serialNumber: String? = null,
|
||||
provider: String? = null,
|
||||
providerContact: String? = null,
|
||||
claimPhone: String? = null,
|
||||
claimEmail: String? = null,
|
||||
claimWebsite: String? = null,
|
||||
purchaseDate: String? = null,
|
||||
startDate: String? = null,
|
||||
endDate: String? = null,
|
||||
// Images
|
||||
images: List<com.mycrib.platform.ImageData> = emptyList()
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_updateState.value = ApiResult.Loading
|
||||
val token = TokenStorage.getToken()
|
||||
if (token != null) {
|
||||
// For now, we'll just update metadata without file support
|
||||
// File/image updates can be added later if needed
|
||||
_updateState.value = documentApi.updateDocument(
|
||||
token = token,
|
||||
id = id,
|
||||
title = title,
|
||||
documentType = documentType,
|
||||
description = description,
|
||||
category = category,
|
||||
tags = tags,
|
||||
notes = notes,
|
||||
contractorId = contractorId,
|
||||
isActive = isActive,
|
||||
itemName = itemName,
|
||||
modelNumber = modelNumber,
|
||||
serialNumber = serialNumber,
|
||||
provider = provider,
|
||||
providerContact = providerContact,
|
||||
claimPhone = claimPhone,
|
||||
claimEmail = claimEmail,
|
||||
claimWebsite = claimWebsite,
|
||||
purchaseDate = purchaseDate,
|
||||
startDate = startDate,
|
||||
endDate = endDate
|
||||
)
|
||||
} else {
|
||||
_updateState.value = ApiResult.Error("Not authenticated", 401)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteDocument(id: Int) {
|
||||
viewModelScope.launch {
|
||||
_deleteState.value = ApiResult.Loading
|
||||
@@ -190,4 +253,20 @@ class DocumentViewModel : ViewModel() {
|
||||
fun resetDownloadState() {
|
||||
_downloadState.value = ApiResult.Idle
|
||||
}
|
||||
|
||||
fun deleteDocumentImage(imageId: Int) {
|
||||
viewModelScope.launch {
|
||||
_deleteImageState.value = ApiResult.Loading
|
||||
val token = TokenStorage.getToken()
|
||||
if (token != null) {
|
||||
_deleteImageState.value = documentApi.deleteDocumentImage(token, imageId)
|
||||
} else {
|
||||
_deleteImageState.value = ApiResult.Error("Not authenticated", 401)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetDeleteImageState() {
|
||||
_deleteImageState.value = ApiResult.Idle
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user