From 611f7d853b010b610ed4d83a6d3029761fdd58e1 Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 11 Nov 2025 14:00:01 -0600 Subject: [PATCH] Add document viewing, editing, and image deletion features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../com/example/mycrib/models/Document.kt | 10 + .../com/example/mycrib/navigation/Routes.kt | 6 + .../com/example/mycrib/network/DocumentApi.kt | 16 + .../mycrib/ui/screens/DocumentDetailScreen.kt | 705 ++++++++++++++++++ .../mycrib/ui/screens/DocumentsScreen.kt | 10 +- .../mycrib/ui/screens/EditDocumentScreen.kt | 643 ++++++++++++++++ .../example/mycrib/ui/screens/MainScreen.kt | 22 + .../mycrib/viewmodel/DocumentViewModel.kt | 79 ++ .../iosApp/Documents/DocumentDetailView.swift | 528 +++++++++++++ .../Documents/DocumentViewModelWrapper.swift | 300 ++++++++ .../Documents/DocumentsWarrantiesView.swift | 10 +- .../iosApp/Documents/EditDocumentView.swift | 330 ++++++++ 12 files changed, 2656 insertions(+), 3 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentDetailScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditDocumentScreen.kt create mode 100644 iosApp/iosApp/Documents/DocumentDetailView.swift create mode 100644 iosApp/iosApp/Documents/DocumentViewModelWrapper.swift create mode 100644 iosApp/iosApp/Documents/EditDocumentView.swift diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Document.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Document.kt index a48c03c..9633e82 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Document.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Document.kt @@ -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 = emptyList(), // Metadata val tags: String? = null, val notes: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt index 1757b70..3b528ee 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/DocumentApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/DocumentApi.kt index 3422a70..09a73c2 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/DocumentApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/DocumentApi.kt @@ -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 { + 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") + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentDetailScreen.kt new file mode 100644 index 0000000..c6d788d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentDetailScreen.kt @@ -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).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, + 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 + ) + } + } + } + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentsScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentsScreen.kt index 90d8be5..3de6138 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentsScreen.kt @@ -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, 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) } } + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditDocumentScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditDocumentScreen.kt new file mode 100644 index 0000000..85784c7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditDocumentScreen.kt @@ -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(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>(emptyList()) } + var newImages by remember { mutableStateOf>(emptyList()) } + var imagesToDelete by remember { mutableStateOf>(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).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).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 -> {} + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt index 9255f63..20d41c5 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt @@ -196,6 +196,9 @@ fun MainScreen( initialDocumentType = documentType ) ) + }, + onNavigateToDocumentDetail = { documentId -> + navController.navigate(DocumentDetailRoute(documentId)) } ) } @@ -213,6 +216,25 @@ fun MainScreen( ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + DocumentDetailScreen( + documentId = route.documentId, + onNavigateBack = { navController.popBackStack() }, + onNavigateToEdit = { documentId -> + navController.navigate(EditDocumentRoute(documentId)) + } + ) + } + + composable { backStackEntry -> + val route = backStackEntry.toRoute() + EditDocumentScreen( + documentId = route.documentId, + onNavigateBack = { navController.popBackStack() } + ) + } + composable { Box(modifier = Modifier.fillMaxSize()) { ProfileScreen( diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/DocumentViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/DocumentViewModel.kt index a7abd5a..b1420c0 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/DocumentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/DocumentViewModel.kt @@ -31,6 +31,9 @@ class DocumentViewModel : ViewModel() { private val _downloadState = MutableStateFlow>(ApiResult.Idle) val downloadState: StateFlow> = _downloadState + private val _deleteImageState = MutableStateFlow>(ApiResult.Idle) + val deleteImageState: StateFlow> = _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 = 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 + } } diff --git a/iosApp/iosApp/Documents/DocumentDetailView.swift b/iosApp/iosApp/Documents/DocumentDetailView.swift new file mode 100644 index 0000000..ad49945 --- /dev/null +++ b/iosApp/iosApp/Documents/DocumentDetailView.swift @@ -0,0 +1,528 @@ +import SwiftUI +import ComposeApp + +struct DocumentDetailView: View { + let documentId: Int32 + @StateObject private var viewModel = DocumentViewModelWrapper() + @Environment(\.dismiss) private var dismiss + @State private var showDeleteAlert = false + @State private var navigateToEdit = false + @State private var showImageViewer = false + @State private var selectedImageIndex = 0 + @State private var deleteSucceeded = false + + var body: some View { + ZStack { + if viewModel.documentDetailState is DocumentDetailStateLoading { + ProgressView("Loading document...") + } else if let successState = viewModel.documentDetailState as? DocumentDetailStateSuccess { + documentDetailContent(document: successState.document) + } else if let errorState = viewModel.documentDetailState as? DocumentDetailStateError { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundColor(.red) + Text(errorState.message) + .foregroundColor(.secondary) + Button("Retry") { + viewModel.loadDocumentDetail(id: documentId) + } + .buttonStyle(.borderedProminent) + } + } else { + EmptyView() + } + } + .navigationTitle("Document Details") + .navigationBarTitleDisplayMode(.inline) + .background( + // Hidden NavigationLink for programmatic navigation to edit + Group { + if let successState = viewModel.documentDetailState as? DocumentDetailStateSuccess { + NavigationLink( + destination: EditDocumentView(document: successState.document), + isActive: $navigateToEdit + ) { + EmptyView() + } + } + } + ) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + if viewModel.documentDetailState is DocumentDetailStateSuccess { + Menu { + Button { + navigateToEdit = true + } label: { + Label("Edit", systemImage: "pencil") + } + + Button(role: .destructive) { + showDeleteAlert = true + } label: { + Label("Delete", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + } + .onAppear { + viewModel.loadDocumentDetail(id: documentId) + } + .alert("Delete Document", isPresented: $showDeleteAlert) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { + viewModel.deleteDocument(id: documentId) + } + } message: { + Text("Are you sure you want to delete this document? This action cannot be undone.") + } + .onReceive(viewModel.$deleteState) { newState in + if newState is DeleteStateSuccess { + deleteSucceeded = true + } + } + .onChange(of: deleteSucceeded) { succeeded in + if succeeded { + dismiss() + } + } + .fullScreenCover(isPresented: $showImageViewer) { + if let successState = viewModel.documentDetailState as? DocumentDetailStateSuccess, !successState.document.images.isEmpty { + ImageViewerSheet( + images: successState.document.images, + selectedIndex: $selectedImageIndex, + onDismiss: { showImageViewer = false } + ) + } + } + } + + @ViewBuilder + private func documentDetailContent(document: Document) -> some View { + ScrollView { + VStack(spacing: 20) { + // Status Badge (for warranties) + if document.documentType == "warranty" { + warrantyStatusCard(document: document) + } + + // Basic Information + VStack(alignment: .leading, spacing: 12) { + sectionHeader("Basic Information") + + detailRow(label: "Title", value: document.title) + detailRow(label: "Type", value: DocumentTypeHelper.displayName(for: document.documentType)) + + if let category = document.category { + detailRow(label: "Category", value: DocumentCategoryHelper.displayName(for: category)) + } + + if let description = document.description_, !description.isEmpty { + detailRow(label: "Description", value: description) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(radius: 2) + + // Warranty/Item Details + if document.documentType == "warranty" { + if document.itemName != nil || document.modelNumber != nil || + document.serialNumber != nil || document.provider != nil { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("Item Details") + + if let itemName = document.itemName { + detailRow(label: "Item Name", value: itemName) + } + if let modelNumber = document.modelNumber { + detailRow(label: "Model Number", value: modelNumber) + } + if let serialNumber = document.serialNumber { + detailRow(label: "Serial Number", value: serialNumber) + } + if let provider = document.provider { + detailRow(label: "Provider", value: provider) + } + if let providerContact = document.providerContact { + detailRow(label: "Provider Contact", value: providerContact) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(radius: 2) + } + + // Claim Information + if document.claimPhone != nil || document.claimEmail != nil || + document.claimWebsite != nil { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("Claim Information") + + if let claimPhone = document.claimPhone { + detailRow(label: "Claim Phone", value: claimPhone) + } + if let claimEmail = document.claimEmail { + detailRow(label: "Claim Email", value: claimEmail) + } + if let claimWebsite = document.claimWebsite { + detailRow(label: "Claim Website", value: claimWebsite) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(radius: 2) + } + + // Dates + if document.purchaseDate != nil || document.startDate != nil || + document.endDate != nil { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("Important Dates") + + if let purchaseDate = document.purchaseDate { + detailRow(label: "Purchase Date", value: purchaseDate) + } + if let startDate = document.startDate { + detailRow(label: "Start Date", value: startDate) + } + if let endDate = document.endDate { + detailRow(label: "End Date", value: endDate) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(radius: 2) + } + } + + // Images + if !document.images.isEmpty { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("Images (\(document.images.count))") + + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { + ForEach(Array(document.images.prefix(6).enumerated()), id: \.element.id) { index, image in + ZStack(alignment: .center) { + AsyncImage(url: URL(string: image.imageUrl)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failure: + Image(systemName: "photo") + .foregroundColor(.gray) + case .empty: + ProgressView() + @unknown default: + EmptyView() + } + } + .frame(height: 100) + .clipped() + .cornerRadius(8) + .onTapGesture { + selectedImageIndex = index + showImageViewer = true + } + + if index == 5 && document.images.count > 6 { + Rectangle() + .fill(Color.black.opacity(0.6)) + .cornerRadius(8) + Text("+\(document.images.count - 6)") + .foregroundColor(.white) + .font(.title2) + .fontWeight(.bold) + } + } + } + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(radius: 2) + } + + // Associations + VStack(alignment: .leading, spacing: 12) { + sectionHeader("Associations") + + if let residenceAddress = document.residenceAddress { + detailRow(label: "Residence", value: residenceAddress) + } + if let contractorName = document.contractorName { + detailRow(label: "Contractor", value: contractorName) + } + if let contractorPhone = document.contractorPhone { + detailRow(label: "Contractor Phone", value: contractorPhone) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(radius: 2) + + // Additional Information + if document.tags != nil || document.notes != nil { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("Additional Information") + + if let tags = document.tags, !tags.isEmpty { + detailRow(label: "Tags", value: tags) + } + if let notes = document.notes, !notes.isEmpty { + detailRow(label: "Notes", value: notes) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(radius: 2) + } + + // File Information + if document.fileUrl != nil { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("Attached File") + + if let fileType = document.fileType { + detailRow(label: "File Type", value: fileType) + } + if let fileSize = document.fileSize { + detailRow(label: "File Size", value: formatFileSize(bytes: Int(fileSize))) + } + + Button(action: { + // TODO: Download file + }) { + HStack { + Image(systemName: "arrow.down.circle") + Text("Download File") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(radius: 2) + } + + // Metadata + VStack(alignment: .leading, spacing: 12) { + sectionHeader("Metadata") + + if let uploadedBy = document.uploadedByUsername { + detailRow(label: "Uploaded By", value: uploadedBy) + } + if let createdAt = document.createdAt { + detailRow(label: "Created", value: createdAt) + } + if let updatedAt = document.updatedAt { + detailRow(label: "Updated", value: updatedAt) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(radius: 2) + } + .padding() + } + .background(Color(.systemGroupedBackground)) + } + + @ViewBuilder + private func warrantyStatusCard(document: Document) -> some View { + let daysUntilExpiration = document.daysUntilExpiration?.int32Value ?? 0 + let statusColor = getStatusColor(isActive: document.isActive, daysUntilExpiration: daysUntilExpiration) + let statusText = getStatusText(isActive: document.isActive, daysUntilExpiration: daysUntilExpiration) + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Status") + .font(.caption) + .foregroundColor(.secondary) + Text(statusText) + .font(.title2) + .fontWeight(.bold) + .foregroundColor(statusColor) + } + + Spacer() + + if document.isActive && daysUntilExpiration >= 0 { + VStack(alignment: .trailing, spacing: 4) { + Text("Days Remaining") + .font(.caption) + .foregroundColor(.secondary) + Text("\(daysUntilExpiration)") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(statusColor) + } + } + } + .padding() + .background(statusColor.opacity(0.1)) + .cornerRadius(12) + } + + @ViewBuilder + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.headline) + .fontWeight(.bold) + } + + @ViewBuilder + private func detailRow(label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(.caption) + .foregroundColor(.secondary) + Text(value) + .font(.body) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func getStatusColor(isActive: Bool, daysUntilExpiration: Int32) -> Color { + if !isActive { + return .gray + } else if daysUntilExpiration < 0 { + return .red + } else if daysUntilExpiration < 30 { + return .orange + } else if daysUntilExpiration < 90 { + return .yellow + } else { + return .green + } + } + + private func getStatusText(isActive: Bool, daysUntilExpiration: Int32) -> String { + if !isActive { + return "Inactive" + } else if daysUntilExpiration < 0 { + return "Expired" + } else if daysUntilExpiration < 30 { + return "Expiring Soon" + } else { + return "Active" + } + } + + private func formatFileSize(bytes: Int) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useAll] + formatter.countStyle = .file + 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) + } + } +} diff --git a/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift b/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift new file mode 100644 index 0000000..36c6902 --- /dev/null +++ b/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift @@ -0,0 +1,300 @@ +import Foundation +import ComposeApp +import SwiftUI + +// State wrappers for SwiftUI +protocol DocumentState {} +struct DocumentStateIdle: DocumentState {} +struct DocumentStateLoading: DocumentState {} +struct DocumentStateSuccess: DocumentState { + let documents: [Document] +} +struct DocumentStateError: DocumentState { + let message: String +} + +protocol DocumentDetailState {} +struct DocumentDetailStateIdle: DocumentDetailState {} +struct DocumentDetailStateLoading: DocumentDetailState {} +struct DocumentDetailStateSuccess: DocumentDetailState { + let document: Document +} +struct DocumentDetailStateError: DocumentDetailState { + let message: String +} + +protocol UpdateState {} +struct UpdateStateIdle: UpdateState {} +struct UpdateStateLoading: UpdateState {} +struct UpdateStateSuccess: UpdateState { + let document: Document +} +struct UpdateStateError: UpdateState { + let message: String +} + +protocol DeleteState {} +struct DeleteStateIdle: DeleteState {} +struct DeleteStateLoading: DeleteState {} +struct DeleteStateSuccess: DeleteState {} +struct DeleteStateError: DeleteState { + let message: String +} + +protocol DeleteImageState {} +struct DeleteImageStateIdle: DeleteImageState {} +struct DeleteImageStateLoading: DeleteImageState {} +struct DeleteImageStateSuccess: DeleteImageState {} +struct DeleteImageStateError: DeleteImageState { + let message: String +} + +class DocumentViewModelWrapper: ObservableObject { + @Published var documentsState: DocumentState = DocumentStateIdle() + @Published var documentDetailState: DocumentDetailState = DocumentDetailStateIdle() + @Published var updateState: UpdateState = UpdateStateIdle() + @Published var deleteState: DeleteState = DeleteStateIdle() + @Published var deleteImageState: DeleteImageState = DeleteImageStateIdle() + + private let documentApi = DocumentApi(client: ApiClient_iosKt.createHttpClient()) + + func loadDocuments( + residenceId: Int32? = nil, + documentType: String? = nil, + category: String? = nil, + contractorId: Int32? = nil, + isActive: Bool? = nil, + expiringSoon: Int32? = nil, + tags: String? = nil, + search: String? = nil + ) { + guard let token = TokenStorage.shared.getToken() else { + DispatchQueue.main.async { + self.documentsState = DocumentStateError(message: "Not authenticated") + } + return + } + + DispatchQueue.main.async { + self.documentsState = DocumentStateLoading() + } + + Task { + do { + let result = try await documentApi.getDocuments( + token: token, + residenceId: residenceId != nil ? KotlinInt(integerLiteral: Int(residenceId!)) : nil, + documentType: documentType, + category: category, + contractorId: contractorId != nil ? KotlinInt(integerLiteral: Int(contractorId!)) : nil, + isActive: isActive != nil ? KotlinBoolean(bool: isActive!) : nil, + expiringSoon: expiringSoon != nil ? KotlinInt(integerLiteral: Int(expiringSoon!)) : nil, + tags: tags, + search: search + ) + + await MainActor.run { + if let success = result as? ApiResultSuccess { + let documents = success.data?.results as? [Document] ?? [] + self.documentsState = DocumentStateSuccess(documents: documents) + } else if let error = result as? ApiResultError { + self.documentsState = DocumentStateError(message: error.message) + } + } + } catch { + await MainActor.run { + self.documentsState = DocumentStateError(message: error.localizedDescription) + } + } + } + } + + func loadDocumentDetail(id: Int32) { + guard let token = TokenStorage.shared.getToken() else { + DispatchQueue.main.async { + self.documentDetailState = DocumentDetailStateError(message: "Not authenticated") + } + return + } + + DispatchQueue.main.async { + self.documentDetailState = DocumentDetailStateLoading() + } + + Task { + do { + let result = try await documentApi.getDocument(token: token, id: id) + + await MainActor.run { + if let success = result as? ApiResultSuccess, let document = success.data { + self.documentDetailState = DocumentDetailStateSuccess(document: document) + } else if let error = result as? ApiResultError { + self.documentDetailState = DocumentDetailStateError(message: error.message) + } + } + } catch { + await MainActor.run { + self.documentDetailState = DocumentDetailStateError(message: error.localizedDescription) + } + } + } + } + + func updateDocument( + id: Int32, + title: String, + documentType: String, + description: String? = nil, + category: String? = nil, + tags: String? = nil, + notes: String? = nil, + isActive: Bool = true, + itemName: String? = nil, + modelNumber: String? = nil, + serialNumber: String? = nil, + provider: String? = nil, + providerContact: String? = nil, + claimPhone: String? = nil, + claimEmail: String? = nil, + claimWebsite: String? = nil, + purchaseDate: String? = nil, + startDate: String? = nil, + endDate: String? = nil + ) { + guard let token = TokenStorage.shared.getToken() else { + DispatchQueue.main.async { + self.updateState = UpdateStateError(message: "Not authenticated") + } + return + } + + DispatchQueue.main.async { + self.updateState = UpdateStateLoading() + } + + Task { + do { + let result = try await documentApi.updateDocument( + token: token, + id: id, + title: title, + documentType: documentType, + description: description, + category: category, + tags: tags, + notes: notes, + contractorId: nil, + isActive: KotlinBoolean(bool: isActive), + itemName: itemName, + modelNumber: modelNumber, + serialNumber: serialNumber, + provider: provider, + providerContact: providerContact, + claimPhone: claimPhone, + claimEmail: claimEmail, + claimWebsite: claimWebsite, + purchaseDate: purchaseDate, + startDate: startDate, + endDate: endDate, + fileBytes: nil, + fileName: nil, + mimeType: nil + ) + + await MainActor.run { + if let success = result as? ApiResultSuccess, let document = success.data { + self.updateState = UpdateStateSuccess(document: document) + // Also refresh the detail state + self.documentDetailState = DocumentDetailStateSuccess(document: document) + } else if let error = result as? ApiResultError { + self.updateState = UpdateStateError(message: error.message) + } + } + } catch { + await MainActor.run { + self.updateState = UpdateStateError(message: error.localizedDescription) + } + } + } + } + + func deleteDocument(id: Int32) { + guard let token = TokenStorage.shared.getToken() else { + DispatchQueue.main.async { + self.deleteState = DeleteStateError(message: "Not authenticated") + } + return + } + + DispatchQueue.main.async { + self.deleteState = DeleteStateLoading() + } + + Task { + do { + let result = try await documentApi.deleteDocument(token: token, id: id) + + await MainActor.run { + if result is ApiResultSuccess { + self.deleteState = DeleteStateSuccess() + } else if let error = result as? ApiResultError { + self.deleteState = DeleteStateError(message: error.message) + } + } + } catch { + await MainActor.run { + self.deleteState = DeleteStateError(message: error.localizedDescription) + } + } + } + } + + func resetUpdateState() { + DispatchQueue.main.async { + self.updateState = UpdateStateIdle() + } + } + + func resetDeleteState() { + DispatchQueue.main.async { + self.deleteState = DeleteStateIdle() + } + } + + func deleteDocumentImage(imageId: Int32) { + guard let token = TokenStorage.shared.getToken() else { + DispatchQueue.main.async { + self.deleteImageState = DeleteImageStateError(message: "Not authenticated") + } + return + } + + DispatchQueue.main.async { + self.deleteImageState = DeleteImageStateLoading() + } + + Task { + do { + let result = try await documentApi.deleteDocumentImage(token: token, imageId: imageId) + + await MainActor.run { + if result is ApiResultSuccess { + self.deleteImageState = DeleteImageStateSuccess() + } else if let error = result as? ApiResultError { + self.deleteImageState = DeleteImageStateError(message: error.message) + } + } + } catch { + await MainActor.run { + self.deleteImageState = DeleteImageStateError(message: error.localizedDescription) + } + } + } + } + + func resetDeleteImageState() { + DispatchQueue.main.async { + self.deleteImageState = DeleteImageStateIdle() + } + } +} diff --git a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift index c0ca479..666ceb1 100644 --- a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift +++ b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift @@ -240,7 +240,10 @@ struct WarrantiesTabContent: View { ScrollView { LazyVStack(spacing: AppSpacing.sm) { ForEach(filteredWarranties, id: \.id) { warranty in - WarrantyCard(document: warranty) + NavigationLink(destination: DocumentDetailView(documentId: warranty.id?.int32Value ?? 0)) { + WarrantyCard(document: warranty) + } + .buttonStyle(PlainButtonStyle()) } } .padding(AppSpacing.md) @@ -286,7 +289,10 @@ struct DocumentsTabContent: View { ScrollView { LazyVStack(spacing: AppSpacing.sm) { ForEach(filteredDocuments, id: \.id) { document in - DocumentCard(document: document) + NavigationLink(destination: DocumentDetailView(documentId: document.id?.int32Value ?? 0)) { + DocumentCard(document: document) + } + .buttonStyle(PlainButtonStyle()) } } .padding(AppSpacing.md) diff --git a/iosApp/iosApp/Documents/EditDocumentView.swift b/iosApp/iosApp/Documents/EditDocumentView.swift new file mode 100644 index 0000000..1423976 --- /dev/null +++ b/iosApp/iosApp/Documents/EditDocumentView.swift @@ -0,0 +1,330 @@ +import SwiftUI +import ComposeApp +import PhotosUI + +struct EditDocumentView: View { + let document: Document + @StateObject private var viewModel = DocumentViewModelWrapper() + @Environment(\.dismiss) private var dismiss + + @State private var title: String + @State private var description: String + @State private var category: String? + @State private var tags: String + @State private var notes: String + @State private var isActive: Bool + + // Image management + @State private var existingImages: [DocumentImage] = [] + @State private var imagesToDelete: Set = [] + @State private var showImagePicker = false + @State private var showCamera = false + + // Warranty-specific fields + @State private var itemName: String + @State private var modelNumber: String + @State private var serialNumber: String + @State private var provider: String + @State private var providerContact: String + @State private var claimPhone: String + @State private var claimEmail: String + @State private var claimWebsite: String + @State private var purchaseDate: String + @State private var startDate: String + @State private var endDate: String + + @State private var showCategoryPicker = false + @State private var showAlert = false + @State private var alertMessage = "" + + init(document: Document) { + self.document = document + _title = State(initialValue: document.title) + _description = State(initialValue: document.description_ ?? "") + _category = State(initialValue: document.category) + _tags = State(initialValue: document.tags ?? "") + _notes = State(initialValue: document.notes ?? "") + _isActive = State(initialValue: document.isActive) + + _itemName = State(initialValue: document.itemName ?? "") + _modelNumber = State(initialValue: document.modelNumber ?? "") + _serialNumber = State(initialValue: document.serialNumber ?? "") + _provider = State(initialValue: document.provider ?? "") + _providerContact = State(initialValue: document.providerContact ?? "") + _claimPhone = State(initialValue: document.claimPhone ?? "") + _claimEmail = State(initialValue: document.claimEmail ?? "") + _claimWebsite = State(initialValue: document.claimWebsite ?? "") + _purchaseDate = State(initialValue: document.purchaseDate ?? "") + _startDate = State(initialValue: document.startDate ?? "") + _endDate = State(initialValue: document.endDate ?? "") + } + + var body: some View { + ZStack { + Form { + // Document Type (Read-only) + Section { + HStack { + Text("Document Type") + Spacer() + Text(DocumentTypeHelper.displayName(for: document.documentType)) + .foregroundColor(.secondary) + } + Text("Document type cannot be changed") + .font(.caption) + .foregroundColor(.secondary) + } + + // Basic Information + Section("Basic Information") { + TextField("Title *", text: $title) + + if document.documentType == "warranty" { + Button(action: { showCategoryPicker = true }) { + HStack { + Text("Category") + Spacer() + Text(category.map { DocumentCategoryHelper.displayName(for: $0) } ?? "Select category") + .foregroundColor(.secondary) + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + TextField("Description", text: $description, axis: .vertical) + .lineLimit(3...5) + } + + // Warranty-specific sections + if document.documentType == "warranty" { + Section("Item Details") { + TextField("Item Name", text: $itemName) + TextField("Model Number", text: $modelNumber) + TextField("Serial Number", text: $serialNumber) + TextField("Provider/Manufacturer", text: $provider) + TextField("Provider Contact", text: $providerContact) + } + + Section("Claim Information") { + TextField("Claim Phone", text: $claimPhone) + .keyboardType(.phonePad) + TextField("Claim Email", text: $claimEmail) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + TextField("Claim Website", text: $claimWebsite) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + } + + Section("Important Dates") { + TextField("Purchase Date (YYYY-MM-DD)", text: $purchaseDate) + TextField("Start Date (YYYY-MM-DD)", text: $startDate) + TextField("End Date (YYYY-MM-DD)", text: $endDate) + } + } + + // Image Management + Section { + let totalImages = existingImages.count - imagesToDelete.count + let imageCountText = "\(totalImages)/10" + + HStack { + Text("Photos (\(imageCountText))") + .font(.headline) + + Spacer() + } + + // Existing Images + if !existingImages.isEmpty { + Text("Existing Images") + .font(.subheadline) + .foregroundColor(.secondary) + + ForEach(existingImages, id: \.id) { image in + if let imageId = image.id, !imagesToDelete.contains(imageId.int32Value) { + HStack { + AsyncImage(url: URL(string: image.imageUrl)) { phase in + switch phase { + case .success(let img): + img + .resizable() + .aspectRatio(contentMode: .fill) + case .failure: + Image(systemName: "photo") + .foregroundColor(.gray) + case .empty: + ProgressView() + @unknown default: + EmptyView() + } + } + .frame(width: 60, height: 60) + .clipped() + .cornerRadius(8) + + Text(image.caption ?? "Image \(imageId)") + .lineLimit(1) + + Spacer() + + Button(action: { + imagesToDelete.insert(imageId.int32Value) + }) { + Image(systemName: "trash") + .foregroundColor(.red) + } + } + } + } + } + + Text("Note: iOS image upload will be available in a future update. You can only delete existing images for now.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Images") + } + + // Additional Information + Section("Additional Information") { + TextField("Tags (comma-separated)", text: $tags) + TextField("Notes", text: $notes, axis: .vertical) + .lineLimit(3...5) + + Toggle("Active", isOn: $isActive) + } + } + } + .navigationTitle("Edit Document") + .navigationBarTitleDisplayMode(.inline) + .onAppear { + existingImages = document.images + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + saveDocument() + } + .disabled(title.isEmpty || viewModel.updateState is UpdateStateLoading) + } + } + .sheet(isPresented: $showCategoryPicker) { + categoryPickerSheet + } + .alert("Update Document", isPresented: $showAlert) { + Button("OK") { + if viewModel.updateState is UpdateStateSuccess { + dismiss() + } + } + } message: { + Text(alertMessage) + } + .onReceive(viewModel.$updateState) { newState in + if newState is UpdateStateSuccess { + alertMessage = "Document updated successfully" + showAlert = true + } else if let errorState = newState as? UpdateStateError { + alertMessage = errorState.message + showAlert = true + } + } + } + + @ViewBuilder + private var categoryPickerSheet: some View { + NavigationView { + List { + Button("None") { + category = nil + showCategoryPicker = false + } + + ForEach(allCategories, id: \.value) { cat in + Button(action: { + category = cat.value + showCategoryPicker = false + }) { + HStack { + Text(cat.displayName) + Spacer() + if category == cat.value { + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + } + } + .foregroundColor(.primary) + } + } + .navigationTitle("Select Category") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + showCategoryPicker = false + } + } + } + } + } + + private var allCategories: [(value: String, displayName: String)] { + [ + ("appliance", "Appliance"), + ("hvac", "HVAC"), + ("plumbing", "Plumbing"), + ("electrical", "Electrical"), + ("roofing", "Roofing"), + ("structural", "Structural"), + ("landscaping", "Landscaping"), + ("general", "General"), + ("other", "Other") + ] + } + + private func saveDocument() { + guard !title.isEmpty else { + alertMessage = "Title is required" + showAlert = true + return + } + + guard let documentId = document.id else { + alertMessage = "Invalid document ID" + showAlert = true + return + } + + // First, delete any images marked for deletion + for imageId in imagesToDelete { + viewModel.deleteDocumentImage(imageId: imageId) + } + + // Then update the document + viewModel.updateDocument( + id: documentId.int32Value, + title: title, + documentType: document.documentType, + description: description.isEmpty ? nil : description, + category: category, + tags: tags.isEmpty ? nil : tags, + notes: notes.isEmpty ? nil : notes, + isActive: isActive, + itemName: itemName.isEmpty ? nil : itemName, + modelNumber: modelNumber.isEmpty ? nil : modelNumber, + serialNumber: serialNumber.isEmpty ? nil : serialNumber, + provider: provider.isEmpty ? nil : provider, + providerContact: providerContact.isEmpty ? nil : providerContact, + claimPhone: claimPhone.isEmpty ? nil : claimPhone, + claimEmail: claimEmail.isEmpty ? nil : claimEmail, + claimWebsite: claimWebsite.isEmpty ? nil : claimWebsite, + purchaseDate: purchaseDate.isEmpty ? nil : purchaseDate, + startDate: startDate.isEmpty ? nil : startDate, + endDate: endDate.isEmpty ? nil : endDate + ) + } +}