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
|
||||
}
|
||||
}
|
||||
461
iosApp/iosApp/Documents/AddDocumentView.swift
Normal file
461
iosApp/iosApp/Documents/AddDocumentView.swift
Normal file
@@ -0,0 +1,461 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
import PhotosUI
|
||||
|
||||
struct AddDocumentView: View {
|
||||
let residenceId: Int32?
|
||||
let initialDocumentType: String
|
||||
@Binding var isPresented: Bool
|
||||
@ObservedObject var documentViewModel: DocumentViewModel
|
||||
@StateObject private var residenceViewModel = ResidenceViewModel()
|
||||
|
||||
// Form fields
|
||||
@State private var title = ""
|
||||
@State private var description = ""
|
||||
@State private var selectedDocumentType: String
|
||||
@State private var selectedCategory: String? = nil
|
||||
@State private var notes = ""
|
||||
@State private var tags = ""
|
||||
|
||||
// Warranty-specific fields
|
||||
@State private var itemName = ""
|
||||
@State private var modelNumber = ""
|
||||
@State private var serialNumber = ""
|
||||
@State private var provider = ""
|
||||
@State private var providerContact = ""
|
||||
@State private var claimPhone = ""
|
||||
@State private var claimEmail = ""
|
||||
@State private var claimWebsite = ""
|
||||
@State private var purchaseDate = ""
|
||||
@State private var startDate = ""
|
||||
@State private var endDate = ""
|
||||
|
||||
// Residence selection (if residenceId is nil)
|
||||
@State private var selectedResidenceId: Int? = nil
|
||||
|
||||
// File picker
|
||||
@State private var selectedPhotoItems: [PhotosPickerItem] = []
|
||||
@State private var selectedImages: [UIImage] = []
|
||||
@State private var showCamera = false
|
||||
|
||||
// Validation errors
|
||||
@State private var titleError = ""
|
||||
@State private var itemNameError = ""
|
||||
@State private var providerError = ""
|
||||
@State private var residenceError = ""
|
||||
|
||||
// UI state
|
||||
@State private var isCreating = false
|
||||
@State private var createError: String? = nil
|
||||
@State private var showValidationAlert = false
|
||||
@State private var validationAlertMessage = ""
|
||||
|
||||
init(residenceId: Int32?, initialDocumentType: String, isPresented: Binding<Bool>, documentViewModel: DocumentViewModel) {
|
||||
self.residenceId = residenceId
|
||||
self.initialDocumentType = initialDocumentType
|
||||
self._isPresented = isPresented
|
||||
self.documentViewModel = documentViewModel
|
||||
self._selectedDocumentType = State(initialValue: initialDocumentType)
|
||||
}
|
||||
|
||||
var isWarranty: Bool {
|
||||
selectedDocumentType == "warranty"
|
||||
}
|
||||
|
||||
var needsResidenceSelection: Bool {
|
||||
residenceId == nil
|
||||
}
|
||||
|
||||
var residencesArray: [(id: Int, name: String)] {
|
||||
guard let residences = residenceViewModel.myResidences?.residences else {
|
||||
return []
|
||||
}
|
||||
return residences.map { residenceWithTasks in
|
||||
(id: Int(residenceWithTasks.id), name: residenceWithTasks.name)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
// Residence Selection (if needed)
|
||||
if needsResidenceSelection {
|
||||
Section(header: Text("Residence")) {
|
||||
if residenceViewModel.isLoading {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Loading residences...")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else if let error = residenceViewModel.errorMessage {
|
||||
Text("Error: \(error)")
|
||||
.foregroundColor(.red)
|
||||
} else if !residencesArray.isEmpty {
|
||||
Picker("Residence", selection: $selectedResidenceId) {
|
||||
Text("Select Residence").tag(nil as Int?)
|
||||
ForEach(residencesArray, id: \.id) { residence in
|
||||
Text(residence.name).tag(residence.id as Int?)
|
||||
}
|
||||
}
|
||||
if !residenceError.isEmpty {
|
||||
Text(residenceError)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Document Type
|
||||
Section(header: Text("Document Type")) {
|
||||
Picker("Type", selection: $selectedDocumentType) {
|
||||
ForEach(DocumentType.allCases, id: \.self) { type in
|
||||
Text(type.displayName).tag(type.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Basic Information
|
||||
Section(header: Text("Basic Information")) {
|
||||
TextField("Title", text: $title)
|
||||
if !titleError.isEmpty {
|
||||
Text(titleError)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
TextField("Description (optional)", text: $description, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
}
|
||||
|
||||
// Warranty-specific fields
|
||||
if isWarranty {
|
||||
Section(header: Text("Warranty Details")) {
|
||||
TextField("Item Name", text: $itemName)
|
||||
if !itemNameError.isEmpty {
|
||||
Text(itemNameError)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
TextField("Model Number (optional)", text: $modelNumber)
|
||||
TextField("Serial Number (optional)", text: $serialNumber)
|
||||
|
||||
TextField("Provider/Company", text: $provider)
|
||||
if !providerError.isEmpty {
|
||||
Text(providerError)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
TextField("Provider Contact (optional)", text: $providerContact)
|
||||
TextField("Claim Phone (optional)", text: $claimPhone)
|
||||
.keyboardType(.phonePad)
|
||||
TextField("Claim Email (optional)", text: $claimEmail)
|
||||
.keyboardType(.emailAddress)
|
||||
.textInputAutocapitalization(.never)
|
||||
TextField("Claim Website (optional)", text: $claimWebsite)
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
|
||||
Section(header: Text("Warranty Dates")) {
|
||||
TextField("Purchase Date (YYYY-MM-DD)", text: $purchaseDate)
|
||||
.keyboardType(.numbersAndPunctuation)
|
||||
TextField("Warranty Start Date (YYYY-MM-DD)", text: $startDate)
|
||||
.keyboardType(.numbersAndPunctuation)
|
||||
TextField("Warranty End Date (YYYY-MM-DD)", text: $endDate)
|
||||
.keyboardType(.numbersAndPunctuation)
|
||||
}
|
||||
}
|
||||
|
||||
// Category
|
||||
if isWarranty || ["inspection", "manual", "receipt"].contains(selectedDocumentType) {
|
||||
Section(header: Text("Category")) {
|
||||
Picker("Category", selection: $selectedCategory) {
|
||||
Text("None").tag(nil as String?)
|
||||
ForEach(DocumentCategory.allCases, id: \.self) { category in
|
||||
Text(category.displayName).tag(category.value as String?)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional Information
|
||||
Section(header: Text("Additional Information")) {
|
||||
TextField("Tags (comma-separated)", text: $tags)
|
||||
TextField("Notes (optional)", text: $notes, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
}
|
||||
|
||||
// Images/Files Section
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: {
|
||||
showCamera = true
|
||||
}) {
|
||||
Label("Take Photo", systemImage: "camera")
|
||||
.frame(maxWidth: .infinity)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
PhotosPicker(
|
||||
selection: $selectedPhotoItems,
|
||||
maxSelectionCount: 5,
|
||||
matching: .images,
|
||||
photoLibrary: .shared()
|
||||
) {
|
||||
Label("Library", systemImage: "photo.on.rectangle.angled")
|
||||
.frame(maxWidth: .infinity)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.onChange(of: selectedPhotoItems) { newItems in
|
||||
Task {
|
||||
selectedImages = []
|
||||
for item in newItems {
|
||||
if let data = try? await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) {
|
||||
selectedImages.append(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Display selected images
|
||||
if !selectedImages.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(selectedImages.indices, id: \.self) { index in
|
||||
ImageThumbnailView(
|
||||
image: selectedImages[index],
|
||||
onRemove: {
|
||||
withAnimation {
|
||||
selectedImages.remove(at: index)
|
||||
selectedPhotoItems.remove(at: index)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Photos (\(selectedImages.count)/5)")
|
||||
} footer: {
|
||||
Text("Add up to 5 photos of the \(isWarranty ? "warranty" : "document").")
|
||||
}
|
||||
|
||||
// Error message
|
||||
if let error = createError {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(isWarranty ? "Add Warranty" : "Add Document")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
isPresented = false
|
||||
}
|
||||
.disabled(isCreating)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(isCreating ? "Saving..." : "Save") {
|
||||
saveDocument()
|
||||
}
|
||||
.disabled(isCreating)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if needsResidenceSelection {
|
||||
residenceViewModel.loadMyResidences()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showCamera) {
|
||||
CameraPickerView { image in
|
||||
if selectedImages.count < 5 {
|
||||
selectedImages.append(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Validation Error", isPresented: $showValidationAlert) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text(validationAlertMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveDocument() {
|
||||
print("🔵 saveDocument called")
|
||||
|
||||
// Reset errors
|
||||
titleError = ""
|
||||
itemNameError = ""
|
||||
providerError = ""
|
||||
residenceError = ""
|
||||
createError = nil
|
||||
|
||||
var hasError = false
|
||||
|
||||
// Validate residence
|
||||
let actualResidenceId: Int32
|
||||
if needsResidenceSelection {
|
||||
print("🔵 needsResidenceSelection: true, selectedResidenceId: \(String(describing: selectedResidenceId))")
|
||||
if selectedResidenceId == nil {
|
||||
residenceError = "Please select a residence"
|
||||
hasError = true
|
||||
print("🔴 Validation failed: No residence selected")
|
||||
return
|
||||
} else {
|
||||
actualResidenceId = Int32(selectedResidenceId!)
|
||||
}
|
||||
} else {
|
||||
print("🔵 Using provided residenceId: \(String(describing: residenceId))")
|
||||
actualResidenceId = residenceId!
|
||||
}
|
||||
|
||||
// Validate title
|
||||
if title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
titleError = "Title is required"
|
||||
hasError = true
|
||||
print("🔴 Validation failed: Title is empty")
|
||||
}
|
||||
|
||||
// Validate warranty fields
|
||||
if isWarranty {
|
||||
print("🔵 isWarranty: true")
|
||||
if itemName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
itemNameError = "Item name is required for warranties"
|
||||
hasError = true
|
||||
print("🔴 Validation failed: Item name is empty")
|
||||
}
|
||||
if provider.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
providerError = "Provider is required for warranties"
|
||||
hasError = true
|
||||
print("🔴 Validation failed: Provider is empty")
|
||||
}
|
||||
}
|
||||
|
||||
if hasError {
|
||||
print("🔴 Validation failed, returning")
|
||||
// Show alert with all validation errors
|
||||
var errors: [String] = []
|
||||
if !residenceError.isEmpty { errors.append(residenceError) }
|
||||
if !titleError.isEmpty { errors.append(titleError) }
|
||||
if !itemNameError.isEmpty { errors.append(itemNameError) }
|
||||
if !providerError.isEmpty { errors.append(providerError) }
|
||||
|
||||
validationAlertMessage = errors.joined(separator: "\n")
|
||||
showValidationAlert = true
|
||||
return
|
||||
}
|
||||
|
||||
print("🟢 Validation passed, creating document...")
|
||||
isCreating = true
|
||||
|
||||
// Prepare file data if images are available
|
||||
var fileBytesList: [KotlinByteArray]? = nil
|
||||
var fileNamesList: [String]? = nil
|
||||
var mimeTypesList: [String]? = nil
|
||||
|
||||
if !selectedImages.isEmpty {
|
||||
var bytesList: [KotlinByteArray] = []
|
||||
var namesList: [String] = []
|
||||
var typesList: [String] = []
|
||||
|
||||
for (index, image) in selectedImages.enumerated() {
|
||||
if let imageData = image.jpegData(compressionQuality: 0.8) {
|
||||
bytesList.append(KotlinByteArray(data: imageData))
|
||||
namesList.append("image_\(index).jpg")
|
||||
typesList.append("image/jpeg")
|
||||
}
|
||||
}
|
||||
|
||||
if !bytesList.isEmpty {
|
||||
fileBytesList = bytesList
|
||||
fileNamesList = namesList
|
||||
mimeTypesList = typesList
|
||||
}
|
||||
}
|
||||
|
||||
// Call the API
|
||||
Task {
|
||||
do {
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
await MainActor.run {
|
||||
createError = "Not authenticated"
|
||||
isCreating = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let result = try await DocumentApi(client: ApiClient_iosKt.createHttpClient()).createDocument(
|
||||
token: token,
|
||||
title: title,
|
||||
documentType: selectedDocumentType,
|
||||
residenceId: actualResidenceId,
|
||||
description: description.isEmpty ? nil : description,
|
||||
category: selectedCategory,
|
||||
tags: tags.isEmpty ? nil : tags,
|
||||
notes: notes.isEmpty ? nil : notes,
|
||||
contractorId: nil,
|
||||
isActive: true,
|
||||
itemName: isWarranty ? itemName : nil,
|
||||
modelNumber: modelNumber.isEmpty ? nil : modelNumber,
|
||||
serialNumber: serialNumber.isEmpty ? nil : serialNumber,
|
||||
provider: isWarranty ? provider : nil,
|
||||
providerContact: providerContact.isEmpty ? nil : providerContact,
|
||||
claimPhone: claimPhone.isEmpty ? nil : claimPhone,
|
||||
claimEmail: claimEmail.isEmpty ? nil : claimEmail,
|
||||
claimWebsite: claimWebsite.isEmpty ? nil : claimWebsite,
|
||||
purchaseDate: purchaseDate.isEmpty ? nil : purchaseDate,
|
||||
startDate: startDate.isEmpty ? nil : startDate,
|
||||
endDate: endDate.isEmpty ? nil : endDate,
|
||||
fileBytes: nil,
|
||||
fileName: nil,
|
||||
mimeType: nil,
|
||||
fileBytesList: fileBytesList,
|
||||
fileNamesList: fileNamesList,
|
||||
mimeTypesList: mimeTypesList
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
if result is ApiResultSuccess<Document> {
|
||||
print("🟢 Document created successfully!")
|
||||
// Reload documents
|
||||
documentViewModel.loadDocuments(
|
||||
residenceId: residenceId,
|
||||
documentType: isWarranty ? "warranty" : nil
|
||||
)
|
||||
isPresented = false
|
||||
} else if let error = result as? ApiResultError {
|
||||
print("🔴 API Error: \(error.message)")
|
||||
createError = error.message
|
||||
isCreating = false
|
||||
} else {
|
||||
print("🔴 Unknown result type: \(type(of: result))")
|
||||
createError = "Unknown error occurred"
|
||||
isCreating = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("🔴 Exception: \(error.localizedDescription)")
|
||||
await MainActor.run {
|
||||
createError = error.localizedDescription
|
||||
isCreating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
184
iosApp/iosApp/Documents/DocumentViewModel.swift
Normal file
184
iosApp/iosApp/Documents/DocumentViewModel.swift
Normal file
@@ -0,0 +1,184 @@
|
||||
import Foundation
|
||||
import ComposeApp
|
||||
|
||||
class DocumentViewModel: ObservableObject {
|
||||
@Published var documents: [Document] = []
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let documentApi = DocumentApi(client: ApiClient_iosKt.createHttpClient())
|
||||
|
||||
func loadDocuments(
|
||||
residenceId: Int32? = nil,
|
||||
documentType: String? = nil,
|
||||
category: String? = nil,
|
||||
contractorId: Int32? = nil,
|
||||
isActive: Bool? = nil,
|
||||
expiringSoon: Int32? = nil,
|
||||
tags: String? = nil,
|
||||
search: String? = nil
|
||||
) {
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
errorMessage = "Not authenticated"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await documentApi.getDocuments(
|
||||
token: token,
|
||||
residenceId: residenceId != nil ? KotlinInt(integerLiteral: Int(residenceId!)) : nil,
|
||||
documentType: documentType,
|
||||
category: category,
|
||||
contractorId: contractorId != nil ? KotlinInt(integerLiteral: Int(contractorId!)) : nil,
|
||||
isActive: isActive != nil ? KotlinBoolean(bool: isActive!) : nil,
|
||||
expiringSoon: expiringSoon != nil ? KotlinInt(integerLiteral: Int(expiringSoon!)) : nil,
|
||||
tags: tags,
|
||||
search: search
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
if let success = result as? ApiResultSuccess<DocumentListResponse> {
|
||||
self.documents = success.data?.results as? [Document] ?? []
|
||||
self.isLoading = false
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.errorMessage = error.message
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createDocument(
|
||||
title: String,
|
||||
documentType: String,
|
||||
residenceId: Int32,
|
||||
description: String? = nil,
|
||||
tags: String? = nil,
|
||||
contractorId: Int32? = nil,
|
||||
fileData: Data? = nil,
|
||||
fileName: String? = nil,
|
||||
mimeType: String? = nil
|
||||
) {
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
errorMessage = "Not authenticated"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await documentApi.createDocument(
|
||||
token: token,
|
||||
title: title,
|
||||
documentType: documentType,
|
||||
residenceId: Int32(residenceId),
|
||||
description: description,
|
||||
category: nil,
|
||||
tags: tags,
|
||||
notes: nil,
|
||||
contractorId: contractorId != nil ? KotlinInt(integerLiteral: Int(contractorId!)) : nil,
|
||||
isActive: true,
|
||||
itemName: nil,
|
||||
modelNumber: nil,
|
||||
serialNumber: nil,
|
||||
provider: nil,
|
||||
providerContact: nil,
|
||||
claimPhone: nil,
|
||||
claimEmail: nil,
|
||||
claimWebsite: nil,
|
||||
purchaseDate: nil,
|
||||
startDate: nil,
|
||||
endDate: nil,
|
||||
fileBytes: nil,
|
||||
fileName: fileName,
|
||||
mimeType: mimeType,
|
||||
fileBytesList: nil,
|
||||
fileNamesList: nil,
|
||||
mimeTypesList: nil
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
if result is ApiResultSuccess<Document> {
|
||||
self.loadDocuments()
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.errorMessage = error.message
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteDocument(id: Int32) {
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
errorMessage = "Not authenticated"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await documentApi.deleteDocument(token: token, id: id)
|
||||
|
||||
await MainActor.run {
|
||||
if result is ApiResultSuccess<KotlinUnit> {
|
||||
self.loadDocuments()
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.errorMessage = error.message
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadDocument(url: String) -> Task<Data?, Error> {
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
return Task { throw NSError(domain: "Not authenticated", code: 401) }
|
||||
}
|
||||
|
||||
return Task {
|
||||
do {
|
||||
let result = try await documentApi.downloadDocument(token: token, url: url)
|
||||
|
||||
if let success = result as? ApiResultSuccess<KotlinByteArray>, let byteArray = success.data {
|
||||
// Convert Kotlin ByteArray to Swift Data
|
||||
var data = Data()
|
||||
for i in 0..<byteArray.size {
|
||||
data.append(UInt8(bitPattern: byteArray.get(index: i)))
|
||||
}
|
||||
return data
|
||||
} else if let error = result as? ApiResultError {
|
||||
throw NSError(domain: error.message, code: error.code?.intValue ?? 0)
|
||||
}
|
||||
return nil
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
567
iosApp/iosApp/Documents/DocumentsWarrantiesView.swift
Normal file
567
iosApp/iosApp/Documents/DocumentsWarrantiesView.swift
Normal file
@@ -0,0 +1,567 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
enum DocumentWarrantyTab {
|
||||
case warranties
|
||||
case documents
|
||||
}
|
||||
|
||||
struct DocumentsWarrantiesView: View {
|
||||
@StateObject private var documentViewModel = DocumentViewModel()
|
||||
@State private var selectedTab: DocumentWarrantyTab = .warranties
|
||||
@State private var searchText = ""
|
||||
@State private var selectedCategory: String? = nil
|
||||
@State private var selectedDocType: String? = nil
|
||||
@State private var showActiveOnly = true
|
||||
@State private var showFilterMenu = false
|
||||
@State private var showAddSheet = false
|
||||
|
||||
let residenceId: Int32?
|
||||
|
||||
var warranties: [Document] {
|
||||
documentViewModel.documents.filter { $0.documentType == "warranty" }
|
||||
}
|
||||
|
||||
var documents: [Document] {
|
||||
documentViewModel.documents.filter { $0.documentType != "warranty" }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppColors.background.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Segmented Control for Tabs
|
||||
Picker("", selection: $selectedTab) {
|
||||
Label("Warranties", systemImage: "checkmark.shield")
|
||||
.tag(DocumentWarrantyTab.warranties)
|
||||
Label("Documents", systemImage: "doc.text")
|
||||
.tag(DocumentWarrantyTab.documents)
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
.padding(.top, AppSpacing.sm)
|
||||
|
||||
// Search Bar
|
||||
SearchBar(text: $searchText, placeholder: "Search...")
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
.padding(.top, AppSpacing.xs)
|
||||
|
||||
// Active Filters
|
||||
if selectedCategory != nil || selectedDocType != nil || showActiveOnly {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: AppSpacing.xs) {
|
||||
if selectedTab == .warranties && showActiveOnly {
|
||||
FilterChip(
|
||||
title: "Active Only",
|
||||
icon: "checkmark.circle.fill",
|
||||
onRemove: { showActiveOnly = false }
|
||||
)
|
||||
}
|
||||
|
||||
if let category = selectedCategory, selectedTab == .warranties {
|
||||
FilterChip(
|
||||
title: category,
|
||||
onRemove: { selectedCategory = nil }
|
||||
)
|
||||
}
|
||||
|
||||
if let docType = selectedDocType, selectedTab == .documents {
|
||||
FilterChip(
|
||||
title: docType,
|
||||
onRemove: { selectedDocType = nil }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
}
|
||||
.padding(.vertical, AppSpacing.xs)
|
||||
}
|
||||
|
||||
// Content
|
||||
if selectedTab == .warranties {
|
||||
WarrantiesTabContent(
|
||||
viewModel: documentViewModel,
|
||||
searchText: searchText
|
||||
)
|
||||
} else {
|
||||
DocumentsTabContent(
|
||||
viewModel: documentViewModel,
|
||||
searchText: searchText
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Documents & Warranties")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
// Active Filter (for warranties)
|
||||
if selectedTab == .warranties {
|
||||
Button(action: {
|
||||
showActiveOnly.toggle()
|
||||
loadWarranties()
|
||||
}) {
|
||||
Image(systemName: showActiveOnly ? "checkmark.circle.fill" : "checkmark.circle")
|
||||
.foregroundColor(showActiveOnly ? AppColors.success : AppColors.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter Menu
|
||||
Menu {
|
||||
if selectedTab == .warranties {
|
||||
Button(action: {
|
||||
selectedCategory = nil
|
||||
loadWarranties()
|
||||
}) {
|
||||
Label("All Categories", systemImage: selectedCategory == nil ? "checkmark" : "")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(DocumentCategory.allCases, id: \.self) { category in
|
||||
Button(action: {
|
||||
selectedCategory = category.displayName
|
||||
loadWarranties()
|
||||
}) {
|
||||
Label(category.displayName, systemImage: selectedCategory == category.displayName ? "checkmark" : "")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(action: {
|
||||
selectedDocType = nil
|
||||
loadDocuments()
|
||||
}) {
|
||||
Label("All Types", systemImage: selectedDocType == nil ? "checkmark" : "")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(DocumentType.allCases, id: \.self) { type in
|
||||
Button(action: {
|
||||
selectedDocType = type.displayName
|
||||
loadDocuments()
|
||||
}) {
|
||||
Label(type.displayName, systemImage: selectedDocType == type.displayName ? "checkmark" : "")
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
.foregroundColor((selectedCategory != nil || selectedDocType != nil) ? AppColors.primary : AppColors.textSecondary)
|
||||
}
|
||||
|
||||
// Add Button
|
||||
Button(action: {
|
||||
showAddSheet = true
|
||||
}) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(AppColors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadWarranties()
|
||||
loadDocuments()
|
||||
}
|
||||
.onChange(of: selectedTab) { _ in
|
||||
if selectedTab == .warranties {
|
||||
loadWarranties()
|
||||
} else {
|
||||
loadDocuments()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddSheet) {
|
||||
AddDocumentView(
|
||||
residenceId: residenceId,
|
||||
initialDocumentType: selectedTab == .warranties ? "warranty" : "other",
|
||||
isPresented: $showAddSheet,
|
||||
documentViewModel: documentViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadWarranties() {
|
||||
documentViewModel.loadDocuments(
|
||||
residenceId: residenceId,
|
||||
documentType: "warranty",
|
||||
category: selectedCategory,
|
||||
isActive: showActiveOnly ? true : nil
|
||||
)
|
||||
}
|
||||
|
||||
private func loadDocuments() {
|
||||
documentViewModel.loadDocuments(
|
||||
residenceId: residenceId,
|
||||
documentType: selectedDocType
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Warranties Tab
|
||||
struct WarrantiesTabContent: View {
|
||||
@ObservedObject var viewModel: DocumentViewModel
|
||||
let searchText: String
|
||||
|
||||
var filteredWarranties: [Document] {
|
||||
let warranties = viewModel.documents.filter { $0.documentType == "warranty" }
|
||||
if searchText.isEmpty {
|
||||
return warranties
|
||||
}
|
||||
return warranties.filter {
|
||||
$0.title.localizedCaseInsensitiveContains(searchText) ||
|
||||
($0.itemName ?? "").localizedCaseInsensitiveContains(searchText) ||
|
||||
($0.provider ?? "").localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if viewModel.isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.scaleEffect(1.2)
|
||||
Spacer()
|
||||
} else if let error = viewModel.errorMessage {
|
||||
Spacer()
|
||||
ErrorView(message: error, retryAction: { viewModel.loadDocuments() })
|
||||
Spacer()
|
||||
} else if filteredWarranties.isEmpty {
|
||||
Spacer()
|
||||
EmptyStateView(
|
||||
icon: "doc.text.viewfinder",
|
||||
title: "No warranties found",
|
||||
message: "Add warranties to track coverage periods"
|
||||
)
|
||||
Spacer()
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppSpacing.sm) {
|
||||
ForEach(filteredWarranties, id: \.id) { warranty in
|
||||
WarrantyCard(document: warranty)
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Documents Tab
|
||||
struct DocumentsTabContent: View {
|
||||
@ObservedObject var viewModel: DocumentViewModel
|
||||
let searchText: String
|
||||
|
||||
var filteredDocuments: [Document] {
|
||||
if searchText.isEmpty {
|
||||
return viewModel.documents
|
||||
}
|
||||
return viewModel.documents.filter {
|
||||
$0.title.localizedCaseInsensitiveContains(searchText) ||
|
||||
($0.description_ ?? "").localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if viewModel.isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.scaleEffect(1.2)
|
||||
Spacer()
|
||||
} else if let error = viewModel.errorMessage {
|
||||
Spacer()
|
||||
ErrorView(message: error, retryAction: { viewModel.loadDocuments() })
|
||||
Spacer()
|
||||
} else if filteredDocuments.isEmpty {
|
||||
Spacer()
|
||||
EmptyStateView(
|
||||
icon: "doc",
|
||||
title: "No documents found",
|
||||
message: "Add documents related to your residence"
|
||||
)
|
||||
Spacer()
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppSpacing.sm) {
|
||||
ForEach(filteredDocuments, id: \.id) { document in
|
||||
DocumentCard(document: document)
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Warranty Card
|
||||
struct WarrantyCard: View {
|
||||
let document: Document
|
||||
|
||||
var daysUntilExpiration: Int {
|
||||
Int(document.daysUntilExpiration ?? 0)
|
||||
}
|
||||
|
||||
var statusColor: Color {
|
||||
if !document.isActive { return .gray }
|
||||
if daysUntilExpiration < 0 { return AppColors.error }
|
||||
if daysUntilExpiration < 30 { return AppColors.warning }
|
||||
if daysUntilExpiration < 90 { return .yellow }
|
||||
return AppColors.success
|
||||
}
|
||||
|
||||
var statusText: String {
|
||||
if !document.isActive { return "Inactive" }
|
||||
if daysUntilExpiration < 0 { return "Expired" }
|
||||
if daysUntilExpiration < 30 { return "Expiring soon" }
|
||||
return "Active"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppSpacing.sm) {
|
||||
// Header
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(document.title)
|
||||
.font(AppTypography.titleMedium)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
|
||||
Text(document.itemName ?? "")
|
||||
.font(AppTypography.bodyMedium)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Status Badge
|
||||
Text(statusText)
|
||||
.font(AppTypography.labelSmall)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(statusColor)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(statusColor.opacity(0.2))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Details
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Provider")
|
||||
.font(AppTypography.labelSmall)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
Text(document.provider ?? "N/A")
|
||||
.font(AppTypography.bodyMedium)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("Expires")
|
||||
.font(AppTypography.labelSmall)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
Text(document.endDate ?? "N/A")
|
||||
.font(AppTypography.bodyMedium)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
if document.isActive && daysUntilExpiration >= 0 {
|
||||
Text("\(daysUntilExpiration) days remaining")
|
||||
.font(AppTypography.labelMedium)
|
||||
.foregroundColor(statusColor)
|
||||
}
|
||||
|
||||
// Category Badge
|
||||
if let category = document.category {
|
||||
Text(getCategoryDisplayName(category))
|
||||
.font(AppTypography.labelSmall)
|
||||
.foregroundColor(Color(hex: "374151"))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color(hex: "E5E7EB"))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(AppColors.surface)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||
}
|
||||
|
||||
private func getCategoryDisplayName(_ category: String) -> String {
|
||||
return DocumentCategory.companion.fromValue(value: category).displayName
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Document Card
|
||||
struct DocumentCard: View {
|
||||
let document: Document
|
||||
|
||||
var typeColor: Color {
|
||||
switch document.documentType {
|
||||
case "warranty": return .blue
|
||||
case "manual": return .purple
|
||||
case "receipt": return AppColors.success
|
||||
case "inspection": return AppColors.warning
|
||||
default: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
var typeIcon: String {
|
||||
switch document.documentType {
|
||||
case "photo": return "photo"
|
||||
case "warranty", "insurance": return "checkmark.shield"
|
||||
case "manual": return "book"
|
||||
case "receipt": return "receipt"
|
||||
default: return "doc.text"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: AppSpacing.md) {
|
||||
// Document Icon
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(typeColor.opacity(0.1))
|
||||
.frame(width: 56, height: 56)
|
||||
|
||||
Image(systemName: typeIcon)
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(typeColor)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(document.title)
|
||||
.font(AppTypography.titleMedium)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
if let description = document.description_, !description.isEmpty {
|
||||
Text(description)
|
||||
.font(AppTypography.bodySmall)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(getDocTypeDisplayName(document.documentType))
|
||||
.font(AppTypography.labelSmall)
|
||||
.foregroundColor(typeColor)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(typeColor.opacity(0.2))
|
||||
.cornerRadius(4)
|
||||
|
||||
if let fileSize = document.fileSize {
|
||||
Text(formatFileSize(Int(fileSize)))
|
||||
.font(AppTypography.labelSmall)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(AppColors.surface)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||
}
|
||||
|
||||
private func getDocTypeDisplayName(_ type: String) -> String {
|
||||
return DocumentType.companion.fromValue(value: type).displayName
|
||||
}
|
||||
|
||||
private func formatFileSize(_ bytes: Int) -> String {
|
||||
var size = Double(bytes)
|
||||
let units = ["B", "KB", "MB", "GB"]
|
||||
var unitIndex = 0
|
||||
|
||||
while size >= 1024 && unitIndex < units.count - 1 {
|
||||
size /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
|
||||
return String(format: "%.1f %@", size, units[unitIndex])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Types
|
||||
extension DocumentCategory: CaseIterable {
|
||||
public static var allCases: [DocumentCategory] {
|
||||
return [.appliance, .hvac, .plumbing, .electrical, .roofing, .structural, .other]
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .appliance: return "Appliance"
|
||||
case .hvac: return "HVAC"
|
||||
case .plumbing: return "Plumbing"
|
||||
case .electrical: return "Electrical"
|
||||
case .roofing: return "Roofing"
|
||||
case .structural: return "Structural"
|
||||
case .other: return "Other"
|
||||
default: return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DocumentType: CaseIterable {
|
||||
public static var allCases: [DocumentType] {
|
||||
return [.warranty, .manual, .receipt, .inspection, .permit, .deed, .insurance, .contract, .photo, .other]
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .warranty: return "Warranty"
|
||||
case .manual: return "Manual"
|
||||
case .receipt: return "Receipt"
|
||||
case .inspection: return "Inspection"
|
||||
case .permit: return "Permit"
|
||||
case .deed: return "Deed"
|
||||
case .insurance: return "Insurance"
|
||||
case .contract: return "Contract"
|
||||
case .photo: return "Photo"
|
||||
case .other: return "Other"
|
||||
default: return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State View
|
||||
struct EmptyStateView: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 64))
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
|
||||
Text(title)
|
||||
.font(AppTypography.titleMedium)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
|
||||
Text(message)
|
||||
.font(AppTypography.bodyMedium)
|
||||
.foregroundColor(AppColors.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(AppSpacing.lg)
|
||||
}
|
||||
}
|
||||
@@ -31,12 +31,20 @@ struct MainTabView: View {
|
||||
.tag(2)
|
||||
|
||||
NavigationView {
|
||||
ProfileTabView()
|
||||
DocumentsWarrantiesView(residenceId: nil)
|
||||
}
|
||||
.tabItem {
|
||||
Label("Profile", systemImage: "person.fill")
|
||||
Label("Documents", systemImage: "doc.text.fill")
|
||||
}
|
||||
.tag(3)
|
||||
|
||||
// NavigationView {
|
||||
// ProfileTabView()
|
||||
// }
|
||||
// .tabItem {
|
||||
// Label("Profile", systemImage: "person.fill")
|
||||
// }
|
||||
// .tag(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user