From e716c919f3ccf8aef7e36afa39ddbdb36bb0491a Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 10 Nov 2025 22:38:34 -0600 Subject: [PATCH] Add documents and warranties feature with image upload support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement complete document management system for warranties, manuals, receipts, and other property documents - Add DocumentsScreen with tabbed interface for warranties and documents - Add AddDocumentScreen with comprehensive form including warranty-specific fields - Integrate image upload functionality (camera + gallery, up to 5 images) - Fix FAB visibility by adding bottom padding to account for navigation bar - Fix content being cut off by bottom navigation bar (96dp padding) - Add DocumentViewModel for state management with CRUD operations - Add DocumentApi for backend communication with multipart image upload - Add Document model with comprehensive field support - Update navigation to include document routes - Add iOS DocumentsWarrantiesView and AddDocumentView for cross-platform support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../com/example/mycrib/models/Document.kt | 145 +++++ .../com/example/mycrib/navigation/Routes.kt | 9 + .../com/example/mycrib/network/DocumentApi.kt | 363 +++++++++++ .../mycrib/ui/screens/AddDocumentScreen.kt | 564 +++++++++++++++++ .../mycrib/ui/screens/DocumentsScreen.kt | 498 +++++++++++++++ .../example/mycrib/ui/screens/MainScreen.kt | 56 +- .../mycrib/viewmodel/DocumentViewModel.kt | 193 ++++++ iosApp/iosApp/Documents/AddDocumentView.swift | 461 ++++++++++++++ .../iosApp/Documents/DocumentViewModel.swift | 184 ++++++ .../Documents/DocumentsWarrantiesView.swift | 567 ++++++++++++++++++ iosApp/iosApp/MainTabView.swift | 12 +- 11 files changed, 3047 insertions(+), 5 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/models/Document.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/network/DocumentApi.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AddDocumentScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentsScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/DocumentViewModel.kt create mode 100644 iosApp/iosApp/Documents/AddDocumentView.swift create mode 100644 iosApp/iosApp/Documents/DocumentViewModel.swift create mode 100644 iosApp/iosApp/Documents/DocumentsWarrantiesView.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 new file mode 100644 index 0000000..a48c03c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Document.kt @@ -0,0 +1,145 @@ +package com.mycrib.shared.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Document( + val id: Int? = null, + val title: String, + @SerialName("document_type") val documentType: String, + val category: String? = null, + val description: String? = null, + @SerialName("file_url") val fileUrl: String? = null, // URL to the file + @SerialName("file_size") val fileSize: Int? = null, + @SerialName("file_type") val fileType: String? = null, + // Warranty-specific fields (only used when documentType == "warranty") + @SerialName("item_name") val itemName: String? = null, + @SerialName("model_number") val modelNumber: String? = null, + @SerialName("serial_number") val serialNumber: String? = null, + val provider: String? = null, + @SerialName("provider_contact") val providerContact: String? = null, + @SerialName("claim_phone") val claimPhone: String? = null, + @SerialName("claim_email") val claimEmail: String? = null, + @SerialName("claim_website") val claimWebsite: String? = null, + @SerialName("purchase_date") val purchaseDate: String? = null, + @SerialName("start_date") val startDate: String? = null, + @SerialName("end_date") val endDate: String? = null, + // Relationships + val residence: Int, + @SerialName("residence_address") val residenceAddress: String? = null, + val contractor: Int? = null, + @SerialName("contractor_name") val contractorName: String? = null, + @SerialName("contractor_phone") val contractorPhone: String? = null, + @SerialName("uploaded_by") val uploadedBy: Int? = null, + @SerialName("uploaded_by_username") val uploadedByUsername: String? = null, + // Metadata + val tags: String? = null, + val notes: String? = null, + @SerialName("is_active") val isActive: Boolean = true, + @SerialName("days_until_expiration") val daysUntilExpiration: Int? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null +) + +@Serializable +data class DocumentCreateRequest( + val title: String, + @SerialName("document_type") val documentType: String, + val category: String? = null, + val description: String? = null, + // Note: file will be handled separately as multipart/form-data + // Warranty-specific fields + @SerialName("item_name") val itemName: String? = null, + @SerialName("model_number") val modelNumber: String? = null, + @SerialName("serial_number") val serialNumber: String? = null, + val provider: String? = null, + @SerialName("provider_contact") val providerContact: String? = null, + @SerialName("claim_phone") val claimPhone: String? = null, + @SerialName("claim_email") val claimEmail: String? = null, + @SerialName("claim_website") val claimWebsite: String? = null, + @SerialName("purchase_date") val purchaseDate: String? = null, + @SerialName("start_date") val startDate: String? = null, + @SerialName("end_date") val endDate: String? = null, + // Relationships + val residence: Int, + val contractor: Int? = null, + // Metadata + val tags: String? = null, + val notes: String? = null, + @SerialName("is_active") val isActive: Boolean = true +) + +@Serializable +data class DocumentUpdateRequest( + val title: String? = null, + @SerialName("document_type") val documentType: String? = null, + val category: String? = null, + val description: String? = null, + // Note: file will be handled separately as multipart/form-data + // Warranty-specific fields + @SerialName("item_name") val itemName: String? = null, + @SerialName("model_number") val modelNumber: String? = null, + @SerialName("serial_number") val serialNumber: String? = null, + val provider: String? = null, + @SerialName("provider_contact") val providerContact: String? = null, + @SerialName("claim_phone") val claimPhone: String? = null, + @SerialName("claim_email") val claimEmail: String? = null, + @SerialName("claim_website") val claimWebsite: String? = null, + @SerialName("purchase_date") val purchaseDate: String? = null, + @SerialName("start_date") val startDate: String? = null, + @SerialName("end_date") val endDate: String? = null, + // Relationships + val contractor: Int? = null, + // Metadata + val tags: String? = null, + val notes: String? = null, + @SerialName("is_active") val isActive: Boolean? = null +) + +@Serializable +data class DocumentListResponse( + val count: Int, + val next: String? = null, + val previous: String? = null, + val results: List +) + +// Document type choices +enum class DocumentType(val value: String, val displayName: String) { + WARRANTY("warranty", "Warranty"), + MANUAL("manual", "User Manual"), + RECEIPT("receipt", "Receipt/Invoice"), + INSPECTION("inspection", "Inspection Report"), + PERMIT("permit", "Permit"), + DEED("deed", "Deed/Title"), + INSURANCE("insurance", "Insurance"), + CONTRACT("contract", "Contract"), + PHOTO("photo", "Photo"), + OTHER("other", "Other"); + + companion object { + fun fromValue(value: String): DocumentType { + return values().find { it.value == value } ?: OTHER + } + } +} + +// Document/Warranty category choices +enum class DocumentCategory(val value: String, val displayName: String) { + APPLIANCE("appliance", "Appliance"), + HVAC("hvac", "HVAC"), + PLUMBING("plumbing", "Plumbing"), + ELECTRICAL("electrical", "Electrical"), + ROOFING("roofing", "Roofing"), + STRUCTURAL("structural", "Structural"), + LANDSCAPING("landscaping", "Landscaping"), + GENERAL("general", "General"), + OTHER("other", "Other"); + + companion object { + fun fromValue(value: String): DocumentCategory { + return values().find { it.value == value } ?: OTHER + } + } +} 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 7ae517e..1757b70 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt @@ -92,6 +92,15 @@ object MainTabContractorsRoute @Serializable data class ContractorDetailRoute(val contractorId: Int) +@Serializable +object MainTabDocumentsRoute + +@Serializable +data class AddDocumentRoute( + val residenceId: Int, + val initialDocumentType: String = "other" +) + @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 new file mode 100644 index 0000000..3422a70 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/DocumentApi.kt @@ -0,0 +1,363 @@ +package com.mycrib.shared.network + +import com.mycrib.shared.models.* +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.http.* +import io.ktor.utils.io.core.* + +class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { + private val baseUrl = ApiClient.getBaseUrl() + + suspend fun getDocuments( + token: String, + residenceId: Int? = null, + documentType: String? = null, + category: String? = null, + contractorId: Int? = null, + isActive: Boolean? = null, + expiringSoon: Int? = null, + tags: String? = null, + search: String? = null + ): ApiResult { + return try { + val response = client.get("$baseUrl/documents/") { + header("Authorization", "Token $token") + residenceId?.let { parameter("residence", it) } + documentType?.let { parameter("document_type", it) } + category?.let { parameter("category", it) } + contractorId?.let { parameter("contractor", it) } + isActive?.let { parameter("is_active", it) } + expiringSoon?.let { parameter("expiring_soon", it) } + tags?.let { parameter("tags", it) } + search?.let { parameter("search", it) } + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to fetch documents", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun getDocument(token: String, id: Int): ApiResult { + return try { + val response = client.get("$baseUrl/documents/$id/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to fetch document", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun createDocument( + token: String, + title: String, + documentType: String, + residenceId: Int, + 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, + // File (optional for warranties) - kept for backwards compatibility + fileBytes: ByteArray? = null, + fileName: String? = null, + mimeType: String? = null, + // Multiple files support + fileBytesList: List? = null, + fileNamesList: List? = null, + mimeTypesList: List? = null + ): ApiResult { + return try { + val response = if ((fileBytesList != null && fileBytesList.isNotEmpty() && fileNamesList != null && mimeTypesList != null) || + (fileBytes != null && fileName != null && mimeType != null)) { + // If files are provided, use multipart/form-data + client.submitFormWithBinaryData( + url = "$baseUrl/documents/", + formData = formData { + append("title", title) + append("document_type", documentType) + append("residence", residenceId.toString()) + description?.let { append("description", it) } + category?.let { append("category", it) } + tags?.let { append("tags", it) } + notes?.let { append("notes", it) } + contractorId?.let { append("contractor", it.toString()) } + append("is_active", isActive.toString()) + // Warranty fields + itemName?.let { append("item_name", it) } + modelNumber?.let { append("model_number", it) } + serialNumber?.let { append("serial_number", it) } + provider?.let { append("provider", it) } + providerContact?.let { append("provider_contact", it) } + claimPhone?.let { append("claim_phone", it) } + claimEmail?.let { append("claim_email", it) } + claimWebsite?.let { append("claim_website", it) } + purchaseDate?.let { append("purchase_date", it) } + startDate?.let { append("start_date", it) } + endDate?.let { append("end_date", it) } + + // Handle multiple files if provided + if (fileBytesList != null && fileBytesList.isNotEmpty() && fileNamesList != null && mimeTypesList != null) { + fileBytesList.forEachIndexed { index, bytes -> + append("files", bytes, Headers.build { + append(HttpHeaders.ContentType, mimeTypesList.getOrElse(index) { "application/octet-stream" }) + append(HttpHeaders.ContentDisposition, "filename=\"${fileNamesList.getOrElse(index) { "file_$index" }}\"") + }) + } + } else if (fileBytes != null && fileName != null && mimeType != null) { + // Single file (backwards compatibility) + append("file", fileBytes, Headers.build { + append(HttpHeaders.ContentType, mimeType) + append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"") + }) + } + } + ) { + header("Authorization", "Token $token") + } + } else { + // If no file, use JSON + val request = DocumentCreateRequest( + title = title, + documentType = documentType, + category = category, + description = description, + itemName = itemName, + modelNumber = modelNumber, + serialNumber = serialNumber, + provider = provider, + providerContact = providerContact, + claimPhone = claimPhone, + claimEmail = claimEmail, + claimWebsite = claimWebsite, + purchaseDate = purchaseDate, + startDate = startDate, + endDate = endDate, + residence = residenceId, + contractor = contractorId, + tags = tags, + notes = notes, + isActive = isActive + ) + client.post("$baseUrl/documents/") { + header("Authorization", "Token $token") + contentType(ContentType.Application.Json) + setBody(request) + } + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + val errorMessage = try { + val errorBody: String = response.body() + "Failed to create document: $errorBody" + } catch (e: Exception) { + "Failed to create document" + } + ApiResult.Error(errorMessage, response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun updateDocument( + token: String, + id: Int, + title: String? = null, + documentType: String? = null, + description: String? = null, + category: String? = null, + tags: String? = null, + notes: String? = null, + contractorId: Int? = null, + isActive: Boolean? = null, + // 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, + // File + fileBytes: ByteArray? = null, + fileName: String? = null, + mimeType: String? = null + ): ApiResult { + return try { + // If file is being updated, use multipart/form-data + val response = if (fileBytes != null && fileName != null && mimeType != null) { + client.submitFormWithBinaryData( + url = "$baseUrl/documents/$id/", + formData = formData { + title?.let { append("title", it) } + documentType?.let { append("document_type", it) } + description?.let { append("description", it) } + category?.let { append("category", it) } + tags?.let { append("tags", it) } + notes?.let { append("notes", it) } + contractorId?.let { append("contractor", it.toString()) } + isActive?.let { append("is_active", it.toString()) } + // Warranty fields + itemName?.let { append("item_name", it) } + modelNumber?.let { append("model_number", it) } + serialNumber?.let { append("serial_number", it) } + provider?.let { append("provider", it) } + providerContact?.let { append("provider_contact", it) } + claimPhone?.let { append("claim_phone", it) } + claimEmail?.let { append("claim_email", it) } + claimWebsite?.let { append("claim_website", it) } + purchaseDate?.let { append("purchase_date", it) } + startDate?.let { append("start_date", it) } + endDate?.let { append("end_date", it) } + append("file", fileBytes, Headers.build { + append(HttpHeaders.ContentType, mimeType) + append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"") + }) + } + ) { + header("Authorization", "Token $token") + method = HttpMethod.Put + } + } else { + // Otherwise use JSON for metadata-only updates + val request = DocumentUpdateRequest( + title = title, + documentType = documentType, + category = category, + description = description, + itemName = itemName, + modelNumber = modelNumber, + serialNumber = serialNumber, + provider = provider, + providerContact = providerContact, + claimPhone = claimPhone, + claimEmail = claimEmail, + claimWebsite = claimWebsite, + purchaseDate = purchaseDate, + startDate = startDate, + endDate = endDate, + contractor = contractorId, + tags = tags, + notes = notes, + isActive = isActive + ) + client.patch("$baseUrl/documents/$id/") { + header("Authorization", "Token $token") + contentType(ContentType.Application.Json) + setBody(request) + } + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + val errorMessage = try { + val errorBody: String = response.body() + "Failed to update document: $errorBody" + } catch (e: Exception) { + "Failed to update document" + } + ApiResult.Error(errorMessage, response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun deleteDocument(token: String, id: Int): ApiResult { + return try { + val response = client.delete("$baseUrl/documents/$id/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(Unit) + } else { + ApiResult.Error("Failed to delete document", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun downloadDocument(token: String, url: String): ApiResult { + return try { + val response = client.get(url) { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to download document", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun activateDocument(token: String, id: Int): ApiResult { + return try { + val response = client.post("$baseUrl/documents/$id/activate/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to activate document", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun deactivateDocument(token: String, id: Int): ApiResult { + return try { + val response = client.post("$baseUrl/documents/$id/deactivate/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to deactivate document", 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/AddDocumentScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AddDocumentScreen.kt new file mode 100644 index 0000000..09e143d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AddDocumentScreen.kt @@ -0,0 +1,564 @@ +package com.mycrib.android.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +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.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mycrib.android.viewmodel.DocumentViewModel +import com.mycrib.android.viewmodel.ResidenceViewModel +import com.mycrib.shared.models.DocumentCategory +import com.mycrib.shared.models.DocumentType +import com.mycrib.shared.models.Residence +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 AddDocumentScreen( + residenceId: Int, + initialDocumentType: String = "other", // "warranty" or other document types + onNavigateBack: () -> Unit, + onDocumentCreated: () -> Unit, + documentViewModel: DocumentViewModel = viewModel { DocumentViewModel() }, + residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() } +) { + // If residenceId is -1, we need to let user select residence + val needsResidenceSelection = residenceId == -1 + var selectedResidence by remember { mutableStateOf(null) } + val residencesState by residenceViewModel.residencesState.collectAsState() + + var title by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var selectedDocumentType by remember { mutableStateOf(initialDocumentType) } + var selectedCategory by remember { mutableStateOf(null) } + var notes by remember { mutableStateOf("") } + var tags by remember { mutableStateOf("") } + + // Image selection + var selectedImages by remember { mutableStateOf>(emptyList()) } + + val imagePicker = rememberImagePicker { images -> + // Limit to 5 images + selectedImages = if (selectedImages.size + images.size <= 5) { + selectedImages + images + } else { + selectedImages + images.take(5 - selectedImages.size) + } + } + + val cameraPicker = rememberCameraPicker { image -> + if (selectedImages.size < 5) { + selectedImages = selectedImages + image + } + } + + // Load residences if needed + LaunchedEffect(needsResidenceSelection) { + if (needsResidenceSelection) { + residenceViewModel.loadResidences() + } + } + + // 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("") } + + // Dropdowns + var documentTypeExpanded by remember { mutableStateOf(false) } + var categoryExpanded by remember { mutableStateOf(false) } + var residenceExpanded by remember { mutableStateOf(false) } + + // Validation errors + var titleError by remember { mutableStateOf("") } + var itemNameError by remember { mutableStateOf("") } + var providerError by remember { mutableStateOf("") } + var residenceError by remember { mutableStateOf("") } + + val createState by documentViewModel.createState.collectAsState() + val isWarranty = selectedDocumentType == "warranty" + + // Handle create success + LaunchedEffect(createState) { + if (createState is ApiResult.Success) { + documentViewModel.resetCreateState() + onDocumentCreated() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (isWarranty) "Add Warranty" else "Add Document") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back") + } + } + ) + } + ) { padding -> + 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) + ) { + // Residence Dropdown (if needed) + if (needsResidenceSelection) { + when (residencesState) { + is ApiResult.Loading -> { + CircularProgressIndicator(modifier = Modifier.size(40.dp)) + } + is ApiResult.Success -> { + val residences = (residencesState as ApiResult.Success>).data + ExposedDropdownMenuBox( + expanded = residenceExpanded, + onExpandedChange = { residenceExpanded = it } + ) { + OutlinedTextField( + value = selectedResidence?.name ?: "Select Residence", + onValueChange = {}, + readOnly = true, + label = { Text("Residence *") }, + isError = residenceError.isNotEmpty(), + supportingText = if (residenceError.isNotEmpty()) { + { Text(residenceError) } + } else null, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = residenceExpanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor() + ) + ExposedDropdownMenu( + expanded = residenceExpanded, + onDismissRequest = { residenceExpanded = false } + ) { + residences.forEach { residence -> + DropdownMenuItem( + text = { Text(residence.name) }, + onClick = { + selectedResidence = residence + residenceError = "" + residenceExpanded = false + } + ) + } + } + } + } + is ApiResult.Error -> { + Text( + "Failed to load residences: ${(residencesState as ApiResult.Error).message}", + color = MaterialTheme.colorScheme.error + ) + } + else -> {} + } + } + + // Document Type Dropdown + ExposedDropdownMenuBox( + expanded = documentTypeExpanded, + onExpandedChange = { documentTypeExpanded = it } + ) { + OutlinedTextField( + value = DocumentType.fromValue(selectedDocumentType).displayName, + onValueChange = {}, + readOnly = true, + label = { Text("Document Type *") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = documentTypeExpanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor() + ) + ExposedDropdownMenu( + expanded = documentTypeExpanded, + onDismissRequest = { documentTypeExpanded = false } + ) { + DocumentType.values().forEach { type -> + DropdownMenuItem( + text = { Text(type.displayName) }, + onClick = { + selectedDocumentType = type.value + documentTypeExpanded = false + } + ) + } + } + } + + // Title + OutlinedTextField( + value = title, + onValueChange = { + title = it + titleError = "" + }, + label = { Text("Title *") }, + isError = titleError.isNotEmpty(), + supportingText = if (titleError.isNotEmpty()) { + { Text(titleError) } + } else null, + modifier = Modifier.fillMaxWidth() + ) + + if (isWarranty) { + // Warranty-specific fields + + OutlinedTextField( + value = itemName, + onValueChange = { + itemName = it + itemNameError = "" + }, + label = { Text("Item Name *") }, + isError = itemNameError.isNotEmpty(), + supportingText = if (itemNameError.isNotEmpty()) { + { Text(itemNameError) } + } else null, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = modelNumber, + onValueChange = { modelNumber = it }, + label = { Text("Model Number") }, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = serialNumber, + onValueChange = { serialNumber = it }, + label = { Text("Serial Number") }, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = provider, + onValueChange = { + provider = it + providerError = "" + }, + label = { Text("Provider/Company *") }, + isError = providerError.isNotEmpty(), + supportingText = if (providerError.isNotEmpty()) { + { Text(providerError) } + } else null, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = providerContact, + onValueChange = { providerContact = it }, + label = { Text("Provider Contact") }, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = claimPhone, + onValueChange = { claimPhone = it }, + label = { Text("Claim Phone") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = claimEmail, + onValueChange = { claimEmail = it }, + label = { Text("Claim Email") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = claimWebsite, + onValueChange = { claimWebsite = it }, + label = { Text("Claim Website") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = purchaseDate, + onValueChange = { purchaseDate = it }, + label = { Text("Purchase Date (YYYY-MM-DD)") }, + placeholder = { Text("2024-01-15") }, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = startDate, + onValueChange = { startDate = it }, + label = { Text("Warranty Start Date (YYYY-MM-DD)") }, + placeholder = { Text("2024-01-15") }, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = endDate, + onValueChange = { endDate = it }, + label = { Text("Warranty End Date (YYYY-MM-DD) *") }, + placeholder = { Text("2025-01-15") }, + modifier = Modifier.fillMaxWidth() + ) + } + + // Description + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description") }, + minLines = 3, + modifier = Modifier.fillMaxWidth() + ) + + // Category Dropdown (for warranties and some documents) + if (isWarranty || selectedDocumentType in listOf("inspection", "manual", "receipt")) { + ExposedDropdownMenuBox( + expanded = categoryExpanded, + onExpandedChange = { categoryExpanded = it } + ) { + OutlinedTextField( + value = selectedCategory?.let { DocumentCategory.fromValue(it).displayName } ?: "Select Category", + onValueChange = {}, + readOnly = true, + label = { Text("Category") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoryExpanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor() + ) + ExposedDropdownMenu( + expanded = categoryExpanded, + onDismissRequest = { categoryExpanded = false } + ) { + DropdownMenuItem( + text = { Text("None") }, + onClick = { + selectedCategory = null + categoryExpanded = false + } + ) + DocumentCategory.values().forEach { category -> + DropdownMenuItem( + text = { Text(category.displayName) }, + onClick = { + selectedCategory = category.value + categoryExpanded = false + } + ) + } + } + } + } + + // Tags + OutlinedTextField( + value = tags, + onValueChange = { tags = it }, + label = { Text("Tags") }, + placeholder = { Text("tag1, tag2, tag3") }, + modifier = Modifier.fillMaxWidth() + ) + + // Notes + OutlinedTextField( + value = notes, + onValueChange = { notes = it }, + label = { Text("Notes") }, + minLines = 3, + modifier = Modifier.fillMaxWidth() + ) + + // Image upload section + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + "Photos (${selectedImages.size}/5)", + style = MaterialTheme.typography.titleSmall + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { cameraPicker() }, + modifier = Modifier.weight(1f), + enabled = selectedImages.size < 5 + ) { + Icon(Icons.Default.CameraAlt, null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Camera") + } + + Button( + onClick = { imagePicker() }, + modifier = Modifier.weight(1f), + enabled = selectedImages.size < 5 + ) { + Icon(Icons.Default.Photo, null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Gallery") + } + } + + // Display selected images + if (selectedImages.isNotEmpty()) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + selectedImages.forEachIndexed { index, image -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Icon( + Icons.Default.Image, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + "Image ${index + 1}", + style = MaterialTheme.typography.bodyMedium + ) + } + IconButton( + onClick = { + selectedImages = selectedImages.filter { it != image } + } + ) { + Icon( + Icons.Default.Close, + contentDescription = "Remove image", + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + } + } + } + + // Error message + if (createState is ApiResult.Error) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + (createState as ApiResult.Error).message, + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + + // Save Button + Button( + onClick = { + // Validate + var hasError = false + + // Determine the actual residenceId to use + val actualResidenceId = if (needsResidenceSelection) { + if (selectedResidence == null) { + residenceError = "Please select a residence" + hasError = true + -1 // placeholder, won't be used due to hasError + } else { + selectedResidence!!.id + } + } else { + residenceId + } + + if (title.isBlank()) { + titleError = "Title is required" + hasError = true + } + + if (isWarranty) { + if (itemName.isBlank()) { + itemNameError = "Item name is required for warranties" + hasError = true + } + if (provider.isBlank()) { + providerError = "Provider is required for warranties" + hasError = true + } + } + + if (!hasError) { + documentViewModel.createDocument( + title = title, + documentType = selectedDocumentType, + residenceId = actualResidenceId, + description = description.ifBlank { null }, + category = selectedCategory, + tags = tags.ifBlank { null }, + notes = notes.ifBlank { null }, + contractorId = null, + isActive = true, + // Warranty fields + itemName = if (isWarranty) itemName else null, + modelNumber = modelNumber.ifBlank { null }, + serialNumber = serialNumber.ifBlank { null }, + provider = if (isWarranty) provider else 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 + images = selectedImages + ) + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = createState !is ApiResult.Loading + ) { + if (createState is ApiResult.Loading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text(if (isWarranty) "Add Warranty" else "Add Document") + } + } + } + } +} 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 new file mode 100644 index 0000000..90d8be5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentsScreen.kt @@ -0,0 +1,498 @@ +package com.mycrib.android.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mycrib.android.viewmodel.DocumentViewModel +import com.mycrib.shared.models.* +import com.mycrib.shared.network.ApiResult +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.todayIn + +enum class DocumentTab { + WARRANTIES, DOCUMENTS +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DocumentsScreen( + onNavigateBack: () -> Unit, + residenceId: Int? = null, + onNavigateToAddDocument: (residenceId: Int, documentType: String) -> Unit = { _, _ -> }, + documentViewModel: DocumentViewModel = viewModel { DocumentViewModel() } +) { + var selectedTab by remember { mutableStateOf(DocumentTab.WARRANTIES) } + val documentsState by documentViewModel.documentsState.collectAsState() + + var selectedCategory by remember { mutableStateOf(null) } + var selectedDocType by remember { mutableStateOf(null) } + var showActiveOnly by remember { mutableStateOf(true) } + var showFiltersMenu by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + // Load warranties by default (documentType="warranty") + documentViewModel.loadDocuments( + residenceId = residenceId, + documentType = "warranty", + isActive = true + ) + } + + LaunchedEffect(selectedTab, selectedCategory, selectedDocType, showActiveOnly) { + if (selectedTab == DocumentTab.WARRANTIES) { + documentViewModel.loadDocuments( + residenceId = residenceId, + documentType = "warranty", + category = selectedCategory, + isActive = if (showActiveOnly) true else null + ) + } else { + documentViewModel.loadDocuments( + residenceId = residenceId, + documentType = selectedDocType + ) + } + } + + Scaffold( + topBar = { + Column { + TopAppBar( + title = { Text("Documents & Warranties", fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back") + } + }, + actions = { + if (selectedTab == DocumentTab.WARRANTIES) { + // Active filter toggle for warranties + IconButton(onClick = { showActiveOnly = !showActiveOnly }) { + Icon( + if (showActiveOnly) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline, + "Filter active", + tint = if (showActiveOnly) Color(0xFF10B981) else LocalContentColor.current + ) + } + } + + // Filter menu + Box { + IconButton(onClick = { showFiltersMenu = true }) { + Icon( + Icons.Default.FilterList, + "Filters", + tint = if (selectedCategory != null || selectedDocType != null) + Color(0xFF3B82F6) else LocalContentColor.current + ) + } + + DropdownMenu( + expanded = showFiltersMenu, + onDismissRequest = { showFiltersMenu = false } + ) { + if (selectedTab == DocumentTab.WARRANTIES) { + DropdownMenuItem( + text = { Text("All Categories") }, + onClick = { + selectedCategory = null + showFiltersMenu = false + } + ) + Divider() + DocumentCategory.values().forEach { category -> + DropdownMenuItem( + text = { Text(category.displayName) }, + onClick = { + selectedCategory = category.value + showFiltersMenu = false + } + ) + } + } else { + DropdownMenuItem( + text = { Text("All Types") }, + onClick = { + selectedDocType = null + showFiltersMenu = false + } + ) + Divider() + DocumentType.values().forEach { type -> + DropdownMenuItem( + text = { Text(type.displayName) }, + onClick = { + selectedDocType = type.value + showFiltersMenu = false + } + ) + } + } + } + } + } + ) + + // Tabs + TabRow(selectedTabIndex = selectedTab.ordinal) { + Tab( + selected = selectedTab == DocumentTab.WARRANTIES, + onClick = { selectedTab = DocumentTab.WARRANTIES }, + text = { Text("Warranties") }, + icon = { Icon(Icons.Default.VerifiedUser, null) } + ) + Tab( + selected = selectedTab == DocumentTab.DOCUMENTS, + onClick = { selectedTab = DocumentTab.DOCUMENTS }, + text = { Text("Documents") }, + icon = { Icon(Icons.Default.Description, null) } + ) + } + } + }, + floatingActionButton = { + Box(modifier = Modifier.padding(bottom = 80.dp)) { + FloatingActionButton( + onClick = { + val documentType = if (selectedTab == DocumentTab.WARRANTIES) "warranty" else "other" + // Pass residenceId even if null - AddDocumentScreen will handle it + onNavigateToAddDocument(residenceId ?: -1, documentType) + }, + containerColor = MaterialTheme.colorScheme.primary + ) { + Icon(Icons.Default.Add, "Add") + } + } + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + when (selectedTab) { + DocumentTab.WARRANTIES -> { + DocumentsTabContent( + state = documentsState, + isWarrantyTab = true, + onRetry = { + documentViewModel.loadDocuments( + residenceId = residenceId, + documentType = "warranty", + category = selectedCategory, + isActive = if (showActiveOnly) true else null + ) + } + ) + } + DocumentTab.DOCUMENTS -> { + DocumentsTabContent( + state = documentsState, + isWarrantyTab = false, + onRetry = { + documentViewModel.loadDocuments( + residenceId = residenceId, + documentType = selectedDocType + ) + } + ) + } + } + } + } +} + +@Composable +fun DocumentsTabContent( + state: ApiResult, + isWarrantyTab: Boolean, + onRetry: () -> Unit +) { + when (state) { + is ApiResult.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + is ApiResult.Success -> { + val documents = state.data.results + if (documents.isEmpty()) { + EmptyState( + icon = if (isWarrantyTab) Icons.Default.ReceiptLong else Icons.Default.Description, + message = if (isWarrantyTab) "No warranties found" else "No documents found" + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(documents) { document -> + DocumentCard(document = document, isWarrantyCard = isWarrantyTab, onClick = { /* TODO */ }) + } + } + } + } + is ApiResult.Error -> { + ErrorState(message = state.message, onRetry = onRetry) + } + is ApiResult.Idle -> {} + } +} + +@Composable +fun DocumentCard(document: Document, isWarrantyCard: Boolean = false, onClick: () -> Unit) { + if (isWarrantyCard) { + // Warranty-specific card layout + val daysUntilExpiration = document.daysUntilExpiration ?: 0 + val statusColor = when { + !document.isActive -> Color.Gray + daysUntilExpiration < 0 -> Color.Red + daysUntilExpiration < 30 -> Color(0xFFF59E0B) + daysUntilExpiration < 90 -> Color(0xFFFBBF24) + else -> Color(0xFF10B981) + } + + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + shape = RoundedCornerShape(12.dp) + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + document.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + document.itemName?.let { itemName -> + Text( + itemName, + style = MaterialTheme.typography.bodyMedium, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Box( + modifier = Modifier + .background(statusColor.copy(alpha = 0.2f), RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + when { + !document.isActive -> "Inactive" + daysUntilExpiration < 0 -> "Expired" + daysUntilExpiration < 30 -> "Expiring soon" + else -> "Active" + }, + style = MaterialTheme.typography.labelSmall, + color = statusColor, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text("Provider", style = MaterialTheme.typography.labelSmall, color = Color.Gray) + Text(document.provider ?: "N/A", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } + Column(horizontalAlignment = Alignment.End) { + Text("Expires", style = MaterialTheme.typography.labelSmall, color = Color.Gray) + Text(document.endDate ?: "N/A", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } + } + + if (document.isActive && daysUntilExpiration >= 0) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + "$daysUntilExpiration days remaining", + style = MaterialTheme.typography.labelMedium, + color = statusColor + ) + } + + document.category?.let { category -> + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .background(Color(0xFFE5E7EB), RoundedCornerShape(6.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + DocumentCategory.fromValue(category).displayName, + style = MaterialTheme.typography.labelSmall, + color = Color(0xFF374151) + ) + } + } + } + } + } else { + // Regular document card layout + val typeColor = when (document.documentType) { + "warranty" -> Color(0xFF3B82F6) + "manual" -> Color(0xFF8B5CF6) + "receipt" -> Color(0xFF10B981) + "inspection" -> Color(0xFFF59E0B) + else -> Color(0xFF6B7280) + } + + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Document icon + Box( + modifier = Modifier + .size(56.dp) + .background(typeColor.copy(alpha = 0.1f), RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + Icon( + when (document.documentType) { + "photo" -> Icons.Default.Image + "warranty", "insurance" -> Icons.Default.VerifiedUser + "manual" -> Icons.Default.MenuBook + "receipt" -> Icons.Default.Receipt + else -> Icons.Default.Description + }, + contentDescription = null, + tint = typeColor, + modifier = Modifier.size(32.dp) + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + document.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + + if (document.description?.isNotBlank() == true) { + Text( + document.description, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .background(typeColor.copy(alpha = 0.2f), RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + DocumentType.fromValue(document.documentType).displayName, + style = MaterialTheme.typography.labelSmall, + color = typeColor + ) + } + + document.fileSize?.let { size -> + Text( + formatFileSize(size), + style = MaterialTheme.typography.labelSmall, + color = Color.Gray + ) + } + } + } + + Icon( + Icons.Default.ChevronRight, + contentDescription = null, + tint = Color.Gray + ) + } + } + } +} + +@Composable +fun EmptyState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String) { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(64.dp), tint = Color.Gray) + Spacer(modifier = Modifier.height(16.dp)) + Text(message, style = MaterialTheme.typography.titleMedium, color = Color.Gray) + } +} + +@Composable +fun ErrorState(message: String, onRetry: () -> Unit) { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon(Icons.Default.Error, contentDescription = null, modifier = Modifier.size(64.dp), tint = Color.Red) + Spacer(modifier = Modifier.height(16.dp)) + Text(message, style = MaterialTheme.typography.bodyLarge, color = Color.Gray) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text("Retry") + } + } +} + +fun formatFileSize(bytes: Int): String { + var size = bytes.toDouble() + for (unit in listOf("B", "KB", "MB", "GB")) { + if (size < 1024.0) { + return "${(size * 10).toInt() / 10.0} $unit" + } + size /= 1024.0 + } + return "${(size * 10).toInt() / 10.0} TB" +} 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 4ea48c8..9255f63 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 @@ -89,12 +89,12 @@ fun MainScreen( ) ) NavigationBarItem( - icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, - label = { Text("Profile") }, + icon = { Icon(Icons.Default.Description, contentDescription = "Documents") }, + label = { Text("Documents") }, selected = selectedTab == 3, onClick = { selectedTab = 3 - navController.navigate(MainTabProfileRoute) { + navController.navigate(MainTabDocumentsRoute) { popUpTo(MainTabResidencesRoute) { inclusive = false } } }, @@ -106,6 +106,24 @@ fun MainScreen( unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant ) ) +// NavigationBarItem( +// icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, +// label = { Text("Profile") }, +// selected = selectedTab == 4, +// onClick = { +// selectedTab = 4 +// navController.navigate(MainTabProfileRoute) { +// popUpTo(MainTabResidencesRoute) { inclusive = false } +// } +// }, +// colors = NavigationBarItemDefaults.colors( +// selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, +// selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer, +// indicatorColor = MaterialTheme.colorScheme.secondaryContainer, +// unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, +// unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant +// ) +// ) } } ) { paddingValues -> @@ -163,6 +181,38 @@ fun MainScreen( } } + composable { + Box(modifier = Modifier.fillMaxSize()) { + DocumentsScreen( + onNavigateBack = { + selectedTab = 0 + navController.navigate(MainTabResidencesRoute) + }, + residenceId = null, + onNavigateToAddDocument = { residenceId, documentType -> + navController.navigate( + AddDocumentRoute( + residenceId = residenceId, + initialDocumentType = documentType + ) + ) + } + ) + } + } + + composable { backStackEntry -> + val route = backStackEntry.toRoute() + AddDocumentScreen( + residenceId = route.residenceId, + initialDocumentType = route.initialDocumentType, + onNavigateBack = { navController.popBackStack() }, + onDocumentCreated = { + 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 new file mode 100644 index 0000000..a7abd5a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/DocumentViewModel.kt @@ -0,0 +1,193 @@ +package com.mycrib.android.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mycrib.shared.models.* +import com.mycrib.shared.network.ApiResult +import com.mycrib.shared.network.DocumentApi +import com.mycrib.storage.TokenStorage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class DocumentViewModel : ViewModel() { + private val documentApi = DocumentApi() + + private val _documentsState = MutableStateFlow>(ApiResult.Idle) + val documentsState: StateFlow> = _documentsState + + private val _documentDetailState = MutableStateFlow>(ApiResult.Idle) + val documentDetailState: StateFlow> = _documentDetailState + + private val _createState = MutableStateFlow>(ApiResult.Idle) + val createState: StateFlow> = _createState + + private val _updateState = MutableStateFlow>(ApiResult.Idle) + val updateState: StateFlow> = _updateState + + private val _deleteState = MutableStateFlow>(ApiResult.Idle) + val deleteState: StateFlow> = _deleteState + + private val _downloadState = MutableStateFlow>(ApiResult.Idle) + val downloadState: StateFlow> = _downloadState + + fun loadDocuments( + residenceId: Int? = null, + documentType: String? = null, + category: String? = null, + contractorId: Int? = null, + isActive: Boolean? = null, + expiringSoon: Int? = null, + tags: String? = null, + search: String? = null + ) { + viewModelScope.launch { + _documentsState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + _documentsState.value = documentApi.getDocuments( + token = token, + residenceId = residenceId, + documentType = documentType, + category = category, + contractorId = contractorId, + isActive = isActive, + expiringSoon = expiringSoon, + tags = tags, + search = search + ) + } else { + _documentsState.value = ApiResult.Error("Not authenticated", 401) + } + } + } + + fun loadDocumentDetail(id: Int) { + viewModelScope.launch { + _documentDetailState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + _documentDetailState.value = documentApi.getDocument(token, id) + } else { + _documentDetailState.value = ApiResult.Error("Not authenticated", 401) + } + } + } + + fun createDocument( + title: String, + documentType: String, + residenceId: Int, + 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 { + _createState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + // Convert ImageData to ByteArrays + val fileBytesList = if (images.isNotEmpty()) { + images.map { it.bytes } + } else null + + val fileNamesList = if (images.isNotEmpty()) { + images.mapIndexed { index, image -> image.fileName.ifBlank { "image_$index.jpg" } } + } else null + + val mimeTypesList = if (images.isNotEmpty()) { + images.map { "image/jpeg" } + } else null + + _createState.value = documentApi.createDocument( + token = token, + title = title, + documentType = documentType, + residenceId = residenceId, + 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, + fileBytes = null, + fileName = null, + mimeType = null, + fileBytesList = fileBytesList, + fileNamesList = fileNamesList, + mimeTypesList = mimeTypesList + ) + } else { + _createState.value = ApiResult.Error("Not authenticated", 401) + } + } + } + + fun deleteDocument(id: Int) { + viewModelScope.launch { + _deleteState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + _deleteState.value = documentApi.deleteDocument(token, id) + } else { + _deleteState.value = ApiResult.Error("Not authenticated", 401) + } + } + } + + fun downloadDocument(url: String) { + viewModelScope.launch { + _downloadState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + _downloadState.value = documentApi.downloadDocument(token, url) + } else { + _downloadState.value = ApiResult.Error("Not authenticated", 401) + } + } + } + + fun resetCreateState() { + _createState.value = ApiResult.Idle + } + + fun resetUpdateState() { + _updateState.value = ApiResult.Idle + } + + fun resetDeleteState() { + _deleteState.value = ApiResult.Idle + } + + fun resetDownloadState() { + _downloadState.value = ApiResult.Idle + } +} diff --git a/iosApp/iosApp/Documents/AddDocumentView.swift b/iosApp/iosApp/Documents/AddDocumentView.swift new file mode 100644 index 0000000..4bffbfe --- /dev/null +++ b/iosApp/iosApp/Documents/AddDocumentView.swift @@ -0,0 +1,461 @@ +import SwiftUI +import ComposeApp +import PhotosUI + +struct AddDocumentView: View { + let residenceId: Int32? + let initialDocumentType: String + @Binding var isPresented: Bool + @ObservedObject var documentViewModel: DocumentViewModel + @StateObject private var residenceViewModel = ResidenceViewModel() + + // Form fields + @State private var title = "" + @State private var description = "" + @State private var selectedDocumentType: String + @State private var selectedCategory: String? = nil + @State private var notes = "" + @State private var tags = "" + + // Warranty-specific fields + @State private var itemName = "" + @State private var modelNumber = "" + @State private var serialNumber = "" + @State private var provider = "" + @State private var providerContact = "" + @State private var claimPhone = "" + @State private var claimEmail = "" + @State private var claimWebsite = "" + @State private var purchaseDate = "" + @State private var startDate = "" + @State private var endDate = "" + + // Residence selection (if residenceId is nil) + @State private var selectedResidenceId: Int? = nil + + // File picker + @State private var selectedPhotoItems: [PhotosPickerItem] = [] + @State private var selectedImages: [UIImage] = [] + @State private var showCamera = false + + // Validation errors + @State private var titleError = "" + @State private var itemNameError = "" + @State private var providerError = "" + @State private var residenceError = "" + + // UI state + @State private var isCreating = false + @State private var createError: String? = nil + @State private var showValidationAlert = false + @State private var validationAlertMessage = "" + + init(residenceId: Int32?, initialDocumentType: String, isPresented: Binding, documentViewModel: DocumentViewModel) { + self.residenceId = residenceId + self.initialDocumentType = initialDocumentType + self._isPresented = isPresented + self.documentViewModel = documentViewModel + self._selectedDocumentType = State(initialValue: initialDocumentType) + } + + var isWarranty: Bool { + selectedDocumentType == "warranty" + } + + var needsResidenceSelection: Bool { + residenceId == nil + } + + var residencesArray: [(id: Int, name: String)] { + guard let residences = residenceViewModel.myResidences?.residences else { + return [] + } + return residences.map { residenceWithTasks in + (id: Int(residenceWithTasks.id), name: residenceWithTasks.name) + } + } + + var body: some View { + NavigationView { + Form { + // Residence Selection (if needed) + if needsResidenceSelection { + Section(header: Text("Residence")) { + if residenceViewModel.isLoading { + HStack { + ProgressView() + Text("Loading residences...") + .foregroundColor(.secondary) + } + } else if let error = residenceViewModel.errorMessage { + Text("Error: \(error)") + .foregroundColor(.red) + } else if !residencesArray.isEmpty { + Picker("Residence", selection: $selectedResidenceId) { + Text("Select Residence").tag(nil as Int?) + ForEach(residencesArray, id: \.id) { residence in + Text(residence.name).tag(residence.id as Int?) + } + } + if !residenceError.isEmpty { + Text(residenceError) + .font(.caption) + .foregroundColor(.red) + } + } + } + } + + // Document Type + Section(header: Text("Document Type")) { + Picker("Type", selection: $selectedDocumentType) { + ForEach(DocumentType.allCases, id: \.self) { type in + Text(type.displayName).tag(type.value) + } + } + } + + // Basic Information + Section(header: Text("Basic Information")) { + TextField("Title", text: $title) + if !titleError.isEmpty { + Text(titleError) + .font(.caption) + .foregroundColor(.red) + } + + TextField("Description (optional)", text: $description, axis: .vertical) + .lineLimit(3...6) + } + + // Warranty-specific fields + if isWarranty { + Section(header: Text("Warranty Details")) { + TextField("Item Name", text: $itemName) + if !itemNameError.isEmpty { + Text(itemNameError) + .font(.caption) + .foregroundColor(.red) + } + + TextField("Model Number (optional)", text: $modelNumber) + TextField("Serial Number (optional)", text: $serialNumber) + + TextField("Provider/Company", text: $provider) + if !providerError.isEmpty { + Text(providerError) + .font(.caption) + .foregroundColor(.red) + } + + TextField("Provider Contact (optional)", text: $providerContact) + TextField("Claim Phone (optional)", text: $claimPhone) + .keyboardType(.phonePad) + TextField("Claim Email (optional)", text: $claimEmail) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + TextField("Claim Website (optional)", text: $claimWebsite) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + } + + Section(header: Text("Warranty Dates")) { + TextField("Purchase Date (YYYY-MM-DD)", text: $purchaseDate) + .keyboardType(.numbersAndPunctuation) + TextField("Warranty Start Date (YYYY-MM-DD)", text: $startDate) + .keyboardType(.numbersAndPunctuation) + TextField("Warranty End Date (YYYY-MM-DD)", text: $endDate) + .keyboardType(.numbersAndPunctuation) + } + } + + // Category + if isWarranty || ["inspection", "manual", "receipt"].contains(selectedDocumentType) { + Section(header: Text("Category")) { + Picker("Category", selection: $selectedCategory) { + Text("None").tag(nil as String?) + ForEach(DocumentCategory.allCases, id: \.self) { category in + Text(category.displayName).tag(category.value as String?) + } + } + } + } + + // Additional Information + Section(header: Text("Additional Information")) { + TextField("Tags (comma-separated)", text: $tags) + TextField("Notes (optional)", text: $notes, axis: .vertical) + .lineLimit(3...6) + } + + // Images/Files Section + Section { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Button(action: { + showCamera = true + }) { + Label("Take Photo", systemImage: "camera") + .frame(maxWidth: .infinity) + .foregroundStyle(.blue) + } + .buttonStyle(.bordered) + + PhotosPicker( + selection: $selectedPhotoItems, + maxSelectionCount: 5, + matching: .images, + photoLibrary: .shared() + ) { + Label("Library", systemImage: "photo.on.rectangle.angled") + .frame(maxWidth: .infinity) + .foregroundStyle(.blue) + } + .buttonStyle(.bordered) + } + .onChange(of: selectedPhotoItems) { newItems in + Task { + selectedImages = [] + for item in newItems { + if let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) { + selectedImages.append(image) + } + } + } + } + + // Display selected images + if !selectedImages.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(selectedImages.indices, id: \.self) { index in + ImageThumbnailView( + image: selectedImages[index], + onRemove: { + withAnimation { + selectedImages.remove(at: index) + selectedPhotoItems.remove(at: index) + } + } + ) + } + } + .padding(.vertical, 4) + } + } + } + } header: { + Text("Photos (\(selectedImages.count)/5)") + } footer: { + Text("Add up to 5 photos of the \(isWarranty ? "warranty" : "document").") + } + + // Error message + if let error = createError { + Section { + Text(error) + .foregroundColor(.red) + } + } + } + .navigationTitle(isWarranty ? "Add Warranty" : "Add Document") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + isPresented = false + } + .disabled(isCreating) + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button(isCreating ? "Saving..." : "Save") { + saveDocument() + } + .disabled(isCreating) + } + } + .onAppear { + if needsResidenceSelection { + residenceViewModel.loadMyResidences() + } + } + .sheet(isPresented: $showCamera) { + CameraPickerView { image in + if selectedImages.count < 5 { + selectedImages.append(image) + } + } + } + .alert("Validation Error", isPresented: $showValidationAlert) { + Button("OK", role: .cancel) { } + } message: { + Text(validationAlertMessage) + } + } + } + + private func saveDocument() { + print("🔵 saveDocument called") + + // Reset errors + titleError = "" + itemNameError = "" + providerError = "" + residenceError = "" + createError = nil + + var hasError = false + + // Validate residence + let actualResidenceId: Int32 + if needsResidenceSelection { + print("🔵 needsResidenceSelection: true, selectedResidenceId: \(String(describing: selectedResidenceId))") + if selectedResidenceId == nil { + residenceError = "Please select a residence" + hasError = true + print("🔴 Validation failed: No residence selected") + return + } else { + actualResidenceId = Int32(selectedResidenceId!) + } + } else { + print("🔵 Using provided residenceId: \(String(describing: residenceId))") + actualResidenceId = residenceId! + } + + // Validate title + if title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + titleError = "Title is required" + hasError = true + print("🔴 Validation failed: Title is empty") + } + + // Validate warranty fields + if isWarranty { + print("🔵 isWarranty: true") + if itemName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + itemNameError = "Item name is required for warranties" + hasError = true + print("🔴 Validation failed: Item name is empty") + } + if provider.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + providerError = "Provider is required for warranties" + hasError = true + print("🔴 Validation failed: Provider is empty") + } + } + + if hasError { + print("🔴 Validation failed, returning") + // Show alert with all validation errors + var errors: [String] = [] + if !residenceError.isEmpty { errors.append(residenceError) } + if !titleError.isEmpty { errors.append(titleError) } + if !itemNameError.isEmpty { errors.append(itemNameError) } + if !providerError.isEmpty { errors.append(providerError) } + + validationAlertMessage = errors.joined(separator: "\n") + showValidationAlert = true + return + } + + print("🟢 Validation passed, creating document...") + isCreating = true + + // Prepare file data if images are available + var fileBytesList: [KotlinByteArray]? = nil + var fileNamesList: [String]? = nil + var mimeTypesList: [String]? = nil + + if !selectedImages.isEmpty { + var bytesList: [KotlinByteArray] = [] + var namesList: [String] = [] + var typesList: [String] = [] + + for (index, image) in selectedImages.enumerated() { + if let imageData = image.jpegData(compressionQuality: 0.8) { + bytesList.append(KotlinByteArray(data: imageData)) + namesList.append("image_\(index).jpg") + typesList.append("image/jpeg") + } + } + + if !bytesList.isEmpty { + fileBytesList = bytesList + fileNamesList = namesList + mimeTypesList = typesList + } + } + + // Call the API + Task { + do { + guard let token = TokenStorage.shared.getToken() else { + await MainActor.run { + createError = "Not authenticated" + isCreating = false + } + return + } + + let result = try await DocumentApi(client: ApiClient_iosKt.createHttpClient()).createDocument( + token: token, + title: title, + documentType: selectedDocumentType, + residenceId: actualResidenceId, + description: description.isEmpty ? nil : description, + category: selectedCategory, + tags: tags.isEmpty ? nil : tags, + notes: notes.isEmpty ? nil : notes, + contractorId: nil, + isActive: true, + itemName: isWarranty ? itemName : nil, + modelNumber: modelNumber.isEmpty ? nil : modelNumber, + serialNumber: serialNumber.isEmpty ? nil : serialNumber, + provider: isWarranty ? provider : nil, + 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, + fileBytes: nil, + fileName: nil, + mimeType: nil, + fileBytesList: fileBytesList, + fileNamesList: fileNamesList, + mimeTypesList: mimeTypesList + ) + + await MainActor.run { + if result is ApiResultSuccess { + print("🟢 Document created successfully!") + // Reload documents + documentViewModel.loadDocuments( + residenceId: residenceId, + documentType: isWarranty ? "warranty" : nil + ) + isPresented = false + } else if let error = result as? ApiResultError { + print("🔴 API Error: \(error.message)") + createError = error.message + isCreating = false + } else { + print("🔴 Unknown result type: \(type(of: result))") + createError = "Unknown error occurred" + isCreating = false + } + } + } catch { + print("🔴 Exception: \(error.localizedDescription)") + await MainActor.run { + createError = error.localizedDescription + isCreating = false + } + } + } + } +} diff --git a/iosApp/iosApp/Documents/DocumentViewModel.swift b/iosApp/iosApp/Documents/DocumentViewModel.swift new file mode 100644 index 0000000..86ca844 --- /dev/null +++ b/iosApp/iosApp/Documents/DocumentViewModel.swift @@ -0,0 +1,184 @@ +import Foundation +import ComposeApp + +class DocumentViewModel: ObservableObject { + @Published var documents: [Document] = [] + @Published var isLoading = false + @Published var errorMessage: String? + + 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 { + errorMessage = "Not authenticated" + return + } + + isLoading = true + errorMessage = nil + + 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 { + self.documents = success.data?.results as? [Document] ?? [] + self.isLoading = false + } else if let error = result as? ApiResultError { + self.errorMessage = error.message + self.isLoading = false + } + } + } catch { + await MainActor.run { + self.errorMessage = error.localizedDescription + self.isLoading = false + } + } + } + } + + func createDocument( + title: String, + documentType: String, + residenceId: Int32, + description: String? = nil, + tags: String? = nil, + contractorId: Int32? = nil, + fileData: Data? = nil, + fileName: String? = nil, + mimeType: String? = nil + ) { + guard let token = TokenStorage.shared.getToken() else { + errorMessage = "Not authenticated" + return + } + + isLoading = true + errorMessage = nil + + Task { + do { + let result = try await documentApi.createDocument( + token: token, + title: title, + documentType: documentType, + residenceId: Int32(residenceId), + description: description, + category: nil, + tags: tags, + notes: nil, + contractorId: contractorId != nil ? KotlinInt(integerLiteral: Int(contractorId!)) : nil, + isActive: true, + itemName: nil, + modelNumber: nil, + serialNumber: nil, + provider: nil, + providerContact: nil, + claimPhone: nil, + claimEmail: nil, + claimWebsite: nil, + purchaseDate: nil, + startDate: nil, + endDate: nil, + fileBytes: nil, + fileName: fileName, + mimeType: mimeType, + fileBytesList: nil, + fileNamesList: nil, + mimeTypesList: nil + ) + + await MainActor.run { + if result is ApiResultSuccess { + self.loadDocuments() + } else if let error = result as? ApiResultError { + self.errorMessage = error.message + self.isLoading = false + } + } + } catch { + await MainActor.run { + self.errorMessage = error.localizedDescription + self.isLoading = false + } + } + } + } + + func deleteDocument(id: Int32) { + guard let token = TokenStorage.shared.getToken() else { + errorMessage = "Not authenticated" + return + } + + isLoading = true + errorMessage = nil + + Task { + do { + let result = try await documentApi.deleteDocument(token: token, id: id) + + await MainActor.run { + if result is ApiResultSuccess { + self.loadDocuments() + } else if let error = result as? ApiResultError { + self.errorMessage = error.message + self.isLoading = false + } + } + } catch { + await MainActor.run { + self.errorMessage = error.localizedDescription + self.isLoading = false + } + } + } + } + + func downloadDocument(url: String) -> Task { + guard let token = TokenStorage.shared.getToken() else { + return Task { throw NSError(domain: "Not authenticated", code: 401) } + } + + return Task { + do { + let result = try await documentApi.downloadDocument(token: token, url: url) + + if let success = result as? ApiResultSuccess, let byteArray = success.data { + // Convert Kotlin ByteArray to Swift Data + var data = Data() + for i in 0..= 0 { + Text("\(daysUntilExpiration) days remaining") + .font(AppTypography.labelMedium) + .foregroundColor(statusColor) + } + + // Category Badge + if let category = document.category { + Text(getCategoryDisplayName(category)) + .font(AppTypography.labelSmall) + .foregroundColor(Color(hex: "374151")) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(hex: "E5E7EB")) + .cornerRadius(4) + } + } + .padding(AppSpacing.md) + .background(AppColors.surface) + .cornerRadius(AppRadius.md) + .shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1) + } + + private func getCategoryDisplayName(_ category: String) -> String { + return DocumentCategory.companion.fromValue(value: category).displayName + } +} + +// MARK: - Document Card +struct DocumentCard: View { + let document: Document + + var typeColor: Color { + switch document.documentType { + case "warranty": return .blue + case "manual": return .purple + case "receipt": return AppColors.success + case "inspection": return AppColors.warning + default: return .gray + } + } + + var typeIcon: String { + switch document.documentType { + case "photo": return "photo" + case "warranty", "insurance": return "checkmark.shield" + case "manual": return "book" + case "receipt": return "receipt" + default: return "doc.text" + } + } + + var body: some View { + HStack(spacing: AppSpacing.md) { + // Document Icon + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(typeColor.opacity(0.1)) + .frame(width: 56, height: 56) + + Image(systemName: typeIcon) + .font(.system(size: 24)) + .foregroundColor(typeColor) + } + + VStack(alignment: .leading, spacing: 4) { + Text(document.title) + .font(AppTypography.titleMedium) + .fontWeight(.bold) + .foregroundColor(AppColors.textPrimary) + .lineLimit(1) + + if let description = document.description_, !description.isEmpty { + Text(description) + .font(AppTypography.bodySmall) + .foregroundColor(AppColors.textSecondary) + .lineLimit(2) + } + + HStack(spacing: 8) { + Text(getDocTypeDisplayName(document.documentType)) + .font(AppTypography.labelSmall) + .foregroundColor(typeColor) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(typeColor.opacity(0.2)) + .cornerRadius(4) + + if let fileSize = document.fileSize { + Text(formatFileSize(Int(fileSize))) + .font(AppTypography.labelSmall) + .foregroundColor(AppColors.textSecondary) + } + } + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(AppColors.textSecondary) + .font(.system(size: 14)) + } + .padding(AppSpacing.md) + .background(AppColors.surface) + .cornerRadius(AppRadius.md) + .shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1) + } + + private func getDocTypeDisplayName(_ type: String) -> String { + return DocumentType.companion.fromValue(value: type).displayName + } + + private func formatFileSize(_ bytes: Int) -> String { + var size = Double(bytes) + let units = ["B", "KB", "MB", "GB"] + var unitIndex = 0 + + while size >= 1024 && unitIndex < units.count - 1 { + size /= 1024 + unitIndex += 1 + } + + return String(format: "%.1f %@", size, units[unitIndex]) + } +} + +// MARK: - Supporting Types +extension DocumentCategory: CaseIterable { + public static var allCases: [DocumentCategory] { + return [.appliance, .hvac, .plumbing, .electrical, .roofing, .structural, .other] + } + + var displayName: String { + switch self { + 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 .other: return "Other" + default: return "Unknown" + } + } +} + +extension DocumentType: CaseIterable { + public static var allCases: [DocumentType] { + return [.warranty, .manual, .receipt, .inspection, .permit, .deed, .insurance, .contract, .photo, .other] + } + + var displayName: String { + switch self { + case .warranty: return "Warranty" + case .manual: return "Manual" + case .receipt: return "Receipt" + case .inspection: return "Inspection" + case .permit: return "Permit" + case .deed: return "Deed" + case .insurance: return "Insurance" + case .contract: return "Contract" + case .photo: return "Photo" + case .other: return "Other" + default: return "Unknown" + } + } +} + +// MARK: - Empty State View +struct EmptyStateView: View { + let icon: String + let title: String + let message: String + + var body: some View { + VStack(spacing: AppSpacing.md) { + Image(systemName: icon) + .font(.system(size: 64)) + .foregroundColor(AppColors.textSecondary) + + Text(title) + .font(AppTypography.titleMedium) + .foregroundColor(AppColors.textSecondary) + + Text(message) + .font(AppTypography.bodyMedium) + .foregroundColor(AppColors.textTertiary) + .multilineTextAlignment(.center) + } + .padding(AppSpacing.lg) + } +} diff --git a/iosApp/iosApp/MainTabView.swift b/iosApp/iosApp/MainTabView.swift index f83673d..3ff85cc 100644 --- a/iosApp/iosApp/MainTabView.swift +++ b/iosApp/iosApp/MainTabView.swift @@ -31,12 +31,20 @@ struct MainTabView: View { .tag(2) NavigationView { - ProfileTabView() + DocumentsWarrantiesView(residenceId: nil) } .tabItem { - Label("Profile", systemImage: "person.fill") + Label("Documents", systemImage: "doc.text.fill") } .tag(3) + +// NavigationView { +// ProfileTabView() +// } +// .tabItem { +// Label("Profile", systemImage: "person.fill") +// } +// .tag(4) } } }