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:
Trey t
2025-11-10 22:38:34 -06:00
parent d3caffa792
commit e716c919f3
11 changed files with 3047 additions and 5 deletions

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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")
}
}
}

View File

@@ -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")
}
}
}
}
}

View File

@@ -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"
}

View File

@@ -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(

View File

@@ -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
}
}