Add documents and warranties feature with image upload support
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<DocumentListResponse> {
|
||||
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<Document> {
|
||||
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<ByteArray>? = null,
|
||||
fileNamesList: List<String>? = null,
|
||||
mimeTypesList: List<String>? = null
|
||||
): ApiResult<Document> {
|
||||
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<Document> {
|
||||
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<Unit> {
|
||||
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<ByteArray> {
|
||||
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<Document> {
|
||||
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<Document> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Residence?>(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<String?>(null) }
|
||||
var notes by remember { mutableStateOf("") }
|
||||
var tags by remember { mutableStateOf("") }
|
||||
|
||||
// Image selection
|
||||
var selectedImages by remember { mutableStateOf<List<ImageData>>(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<List<Residence>>).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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String?>(null) }
|
||||
var selectedDocType by remember { mutableStateOf<String?>(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<DocumentListResponse>,
|
||||
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"
|
||||
}
|
||||
@@ -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<MainTabDocumentsRoute> {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
DocumentsScreen(
|
||||
onNavigateBack = {
|
||||
selectedTab = 0
|
||||
navController.navigate(MainTabResidencesRoute)
|
||||
},
|
||||
residenceId = null,
|
||||
onNavigateToAddDocument = { residenceId, documentType ->
|
||||
navController.navigate(
|
||||
AddDocumentRoute(
|
||||
residenceId = residenceId,
|
||||
initialDocumentType = documentType
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable<AddDocumentRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<AddDocumentRoute>()
|
||||
AddDocumentScreen(
|
||||
residenceId = route.residenceId,
|
||||
initialDocumentType = route.initialDocumentType,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onDocumentCreated = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<MainTabProfileRoute> {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ProfileScreen(
|
||||
|
||||
@@ -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<DocumentListResponse>>(ApiResult.Idle)
|
||||
val documentsState: StateFlow<ApiResult<DocumentListResponse>> = _documentsState
|
||||
|
||||
private val _documentDetailState = MutableStateFlow<ApiResult<Document>>(ApiResult.Idle)
|
||||
val documentDetailState: StateFlow<ApiResult<Document>> = _documentDetailState
|
||||
|
||||
private val _createState = MutableStateFlow<ApiResult<Document>>(ApiResult.Idle)
|
||||
val createState: StateFlow<ApiResult<Document>> = _createState
|
||||
|
||||
private val _updateState = MutableStateFlow<ApiResult<Document>>(ApiResult.Idle)
|
||||
val updateState: StateFlow<ApiResult<Document>> = _updateState
|
||||
|
||||
private val _deleteState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
||||
val deleteState: StateFlow<ApiResult<Unit>> = _deleteState
|
||||
|
||||
private val _downloadState = MutableStateFlow<ApiResult<ByteArray>>(ApiResult.Idle)
|
||||
val downloadState: StateFlow<ApiResult<ByteArray>> = _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<com.mycrib.platform.ImageData> = 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user