Complete iOS document form implementation and improve login error handling
This commit completes the DRY refactoring by implementing the missing document form functionality and enhancing user experience with better error messages. ## iOS Document Forms - Implemented complete createDocument() method in DocumentViewModel: - Support for all warranty-specific fields (itemName, modelNumber, serialNumber, provider, etc.) - Multiple image uploads with JPEG compression - Proper UIImage to KotlinByteArray conversion - Async completion handlers - Implemented updateDocument() method with full field support - Completed DocumentFormView submitForm() implementation with proper API calls - Fixed type conversion issues (Bool/KotlinBoolean, Int32/KotlinInt) - Added proper error handling and user feedback ## iOS Login Error Handling - Enhanced error messages to be user-friendly and concise - Added specific messages for common HTTP error codes (400, 401, 403, 404, 500+) - Implemented cleanErrorMessage() helper to remove technical jargon - Added network-specific error handling (connection, timeout) - Fixed MainActor isolation warnings with proper Task wrapping ## Code Quality - Removed ~4,086 lines of duplicate code through form consolidation - Added 429 lines of new shared form components - Fixed Swift compiler performance issues - Ensured both iOS and Android builds succeed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,299 +1,19 @@
|
||||
package com.mycrib.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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 com.mycrib.repository.LookupsRepository
|
||||
import com.mycrib.shared.models.TaskCategory
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.mycrib.shared.models.TaskCreateRequest
|
||||
import com.mycrib.shared.models.TaskFrequency
|
||||
import com.mycrib.shared.models.TaskPriority
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddNewTaskDialog(
|
||||
residenceId: Int,
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (TaskCreateRequest) -> Unit
|
||||
) {
|
||||
var title by remember { mutableStateOf("") }
|
||||
var description by remember { mutableStateOf("") }
|
||||
var intervalDays by remember { mutableStateOf("") }
|
||||
var dueDate by remember { mutableStateOf("") }
|
||||
var estimatedCost by remember { mutableStateOf("") }
|
||||
|
||||
|
||||
var category by remember { mutableStateOf(TaskCategory(id = 0, name = "")) }
|
||||
var frequency by remember { mutableStateOf(TaskFrequency(
|
||||
id = 0, name = "", lookupName = "", displayName = "",
|
||||
daySpan = 0,
|
||||
notifyDays = 0
|
||||
)) }
|
||||
var priority by remember { mutableStateOf(TaskPriority(id = 0, name = "", displayName = "")) }
|
||||
|
||||
var showFrequencyDropdown by remember { mutableStateOf(false) }
|
||||
var showPriorityDropdown by remember { mutableStateOf(false) }
|
||||
var showCategoryDropdown by remember { mutableStateOf(false) }
|
||||
|
||||
var titleError by remember { mutableStateOf(false) }
|
||||
var categoryError by remember { mutableStateOf(false) }
|
||||
var dueDateError by remember { mutableStateOf(false) }
|
||||
|
||||
// Get data from LookupsRepository
|
||||
val frequencies by LookupsRepository.taskFrequencies.collectAsState()
|
||||
val priorities by LookupsRepository.taskPriorities.collectAsState()
|
||||
val categories by LookupsRepository.taskCategories.collectAsState()
|
||||
|
||||
// Set defaults when data loads
|
||||
LaunchedEffect(frequencies) {
|
||||
if (frequencies.isNotEmpty()) {
|
||||
frequency = frequencies.first()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(priorities) {
|
||||
if (priorities.isNotEmpty()) {
|
||||
priority = priorities.first()
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Add New Task") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Title
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = {
|
||||
title = it
|
||||
titleError = false
|
||||
},
|
||||
label = { Text("Title *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = titleError,
|
||||
supportingText = if (titleError) {
|
||||
{ Text("Title is required") }
|
||||
} else null,
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Description
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Description") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
maxLines = 4
|
||||
)
|
||||
|
||||
// Category
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showCategoryDropdown,
|
||||
onExpandedChange = { showCategoryDropdown = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = categories.find { it == category }?.name ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text("Category *") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
isError = categoryError,
|
||||
supportingText = if (categoryError) {
|
||||
{ Text("Category is required") }
|
||||
} else null,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showCategoryDropdown) },
|
||||
readOnly = false,
|
||||
enabled = categories.isNotEmpty()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = showCategoryDropdown,
|
||||
onDismissRequest = { showCategoryDropdown = false }
|
||||
) {
|
||||
categories.forEach { cat ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(cat.name) },
|
||||
onClick = {
|
||||
category = cat
|
||||
categoryError = false
|
||||
showCategoryDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Frequency
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showFrequencyDropdown,
|
||||
onExpandedChange = { showFrequencyDropdown = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = frequencies.find { it == frequency }?.displayName ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text("Frequency") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showFrequencyDropdown) },
|
||||
enabled = frequencies.isNotEmpty()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = showFrequencyDropdown,
|
||||
onDismissRequest = { showFrequencyDropdown = false }
|
||||
) {
|
||||
frequencies.forEach { freq ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(freq.displayName) },
|
||||
onClick = {
|
||||
frequency = freq
|
||||
showFrequencyDropdown = false
|
||||
// Clear interval days if frequency is "once"
|
||||
if (freq.name == "once") {
|
||||
intervalDays = ""
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interval Days (only for recurring tasks)
|
||||
if (frequency.name != "once") {
|
||||
OutlinedTextField(
|
||||
value = intervalDays,
|
||||
onValueChange = { intervalDays = it.filter { char -> char.isDigit() } },
|
||||
label = { Text("Interval Days (optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
supportingText = { Text("Override default frequency interval") },
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
// Due Date
|
||||
OutlinedTextField(
|
||||
value = dueDate,
|
||||
onValueChange = {
|
||||
dueDate = it
|
||||
dueDateError = false
|
||||
},
|
||||
label = { Text("Due Date (YYYY-MM-DD) *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = dueDateError,
|
||||
supportingText = if (dueDateError) {
|
||||
{ Text("Due date is required (format: YYYY-MM-DD)") }
|
||||
} else {
|
||||
{ Text("Format: YYYY-MM-DD") }
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Priority
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showPriorityDropdown,
|
||||
onExpandedChange = { showPriorityDropdown = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = priorities.find { it.name == priority.name }?.displayName ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text("Priority") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showPriorityDropdown) },
|
||||
enabled = priorities.isNotEmpty()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = showPriorityDropdown,
|
||||
onDismissRequest = { showPriorityDropdown = false }
|
||||
) {
|
||||
priorities.forEach { prio ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(prio.displayName) },
|
||||
onClick = {
|
||||
priority = prio
|
||||
showPriorityDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Estimated Cost
|
||||
OutlinedTextField(
|
||||
value = estimatedCost,
|
||||
onValueChange = { estimatedCost = it },
|
||||
label = { Text("Estimated Cost") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
prefix = { Text("$") },
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
// Validation
|
||||
var hasError = false
|
||||
|
||||
if (title.isBlank()) {
|
||||
titleError = true
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (dueDate.isBlank() || !isValidDateFormat(dueDate)) {
|
||||
dueDateError = true
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (!hasError) {
|
||||
onCreate(
|
||||
TaskCreateRequest(
|
||||
residence = residenceId,
|
||||
title = title,
|
||||
description = description.ifBlank { null },
|
||||
category = category.id,
|
||||
frequency = frequency.id,
|
||||
intervalDays = intervalDays.toIntOrNull(),
|
||||
priority = priority.id,
|
||||
status = null,
|
||||
dueDate = dueDate,
|
||||
estimatedCost = estimatedCost.ifBlank { null }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Create Task")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
AddTaskDialog(
|
||||
residenceId = residenceId,
|
||||
residencesResponse = null,
|
||||
onDismiss = onDismiss,
|
||||
onCreate = onCreate
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to validate date format
|
||||
private fun isValidDateFormat(date: String): Boolean {
|
||||
val datePattern = Regex("^\\d{4}-\\d{2}-\\d{2}$")
|
||||
return datePattern.matches(date)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
package com.mycrib.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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 com.mycrib.repository.LookupsRepository
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.mycrib.shared.models.MyResidencesResponse
|
||||
import com.mycrib.shared.models.TaskCategory
|
||||
import com.mycrib.shared.models.TaskCreateRequest
|
||||
import com.mycrib.shared.models.TaskFrequency
|
||||
import com.mycrib.shared.models.TaskPriority
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddNewTaskWithResidenceDialog(
|
||||
residencesResponse: MyResidencesResponse,
|
||||
@@ -25,341 +12,12 @@ fun AddNewTaskWithResidenceDialog(
|
||||
isLoading: Boolean = false,
|
||||
errorMessage: String? = null
|
||||
) {
|
||||
var title by remember { mutableStateOf("") }
|
||||
var description by remember { mutableStateOf("") }
|
||||
var intervalDays by remember { mutableStateOf("") }
|
||||
var dueDate by remember { mutableStateOf("") }
|
||||
var estimatedCost by remember { mutableStateOf("") }
|
||||
|
||||
var selectedResidenceId by remember { mutableStateOf(residencesResponse.residences.firstOrNull()?.id ?: 0) }
|
||||
var category by remember { mutableStateOf(TaskCategory(id = 0, name = "")) }
|
||||
var frequency by remember { mutableStateOf(TaskFrequency(
|
||||
id = 0, name = "", lookupName = "", displayName = "",
|
||||
daySpan = 0,
|
||||
notifyDays = 0
|
||||
)) }
|
||||
var priority by remember { mutableStateOf(TaskPriority(id = 0, name = "", displayName = "")) }
|
||||
|
||||
var showResidenceDropdown by remember { mutableStateOf(false) }
|
||||
var showFrequencyDropdown by remember { mutableStateOf(false) }
|
||||
var showPriorityDropdown by remember { mutableStateOf(false) }
|
||||
var showCategoryDropdown by remember { mutableStateOf(false) }
|
||||
|
||||
var titleError by remember { mutableStateOf(false) }
|
||||
var categoryError by remember { mutableStateOf(false) }
|
||||
var dueDateError by remember { mutableStateOf(false) }
|
||||
var residenceError by remember { mutableStateOf(false) }
|
||||
|
||||
// Get data from LookupsRepository
|
||||
val frequencies by LookupsRepository.taskFrequencies.collectAsState()
|
||||
val priorities by LookupsRepository.taskPriorities.collectAsState()
|
||||
val categories by LookupsRepository.taskCategories.collectAsState()
|
||||
|
||||
// Set defaults when data loads
|
||||
LaunchedEffect(frequencies) {
|
||||
if (frequencies.isNotEmpty()) {
|
||||
frequency = frequencies.first()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(priorities) {
|
||||
if (priorities.isNotEmpty()) {
|
||||
priority = priorities.first()
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Add New Task") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Residence Selector
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showResidenceDropdown,
|
||||
onExpandedChange = { showResidenceDropdown = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = residencesResponse.residences.find { it.id == selectedResidenceId }?.name ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text("Property *") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
isError = residenceError,
|
||||
supportingText = if (residenceError) {
|
||||
{ Text("Property is required") }
|
||||
} else null,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showResidenceDropdown) },
|
||||
readOnly = true,
|
||||
enabled = residencesResponse.residences.isNotEmpty()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = showResidenceDropdown,
|
||||
onDismissRequest = { showResidenceDropdown = false }
|
||||
) {
|
||||
residencesResponse.residences.forEach { residence ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(residence.name) },
|
||||
onClick = {
|
||||
selectedResidenceId = residence.id
|
||||
residenceError = false
|
||||
showResidenceDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = {
|
||||
title = it
|
||||
titleError = false
|
||||
},
|
||||
label = { Text("Title *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = titleError,
|
||||
supportingText = if (titleError) {
|
||||
{ Text("Title is required") }
|
||||
} else null,
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Description
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Description") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
maxLines = 4
|
||||
)
|
||||
|
||||
// Category
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showCategoryDropdown,
|
||||
onExpandedChange = { showCategoryDropdown = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = categories.find { it == category }?.name ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text("Category *") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
isError = categoryError,
|
||||
supportingText = if (categoryError) {
|
||||
{ Text("Category is required") }
|
||||
} else null,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showCategoryDropdown) },
|
||||
readOnly = false,
|
||||
enabled = categories.isNotEmpty()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = showCategoryDropdown,
|
||||
onDismissRequest = { showCategoryDropdown = false }
|
||||
) {
|
||||
categories.forEach { cat ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(cat.name) },
|
||||
onClick = {
|
||||
category = cat
|
||||
categoryError = false
|
||||
showCategoryDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Frequency
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showFrequencyDropdown,
|
||||
onExpandedChange = { showFrequencyDropdown = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = frequencies.find { it == frequency }?.displayName ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text("Frequency") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showFrequencyDropdown) },
|
||||
enabled = frequencies.isNotEmpty()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = showFrequencyDropdown,
|
||||
onDismissRequest = { showFrequencyDropdown = false }
|
||||
) {
|
||||
frequencies.forEach { freq ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(freq.displayName) },
|
||||
onClick = {
|
||||
frequency = freq
|
||||
showFrequencyDropdown = false
|
||||
// Clear interval days if frequency is "once"
|
||||
if (freq.name == "once") {
|
||||
intervalDays = ""
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interval Days (only for recurring tasks)
|
||||
if (frequency.name != "once") {
|
||||
OutlinedTextField(
|
||||
value = intervalDays,
|
||||
onValueChange = { intervalDays = it.filter { char -> char.isDigit() } },
|
||||
label = { Text("Interval Days (optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
supportingText = { Text("Override default frequency interval") },
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
// Due Date
|
||||
OutlinedTextField(
|
||||
value = dueDate,
|
||||
onValueChange = {
|
||||
dueDate = it
|
||||
dueDateError = false
|
||||
},
|
||||
label = { Text("Due Date (YYYY-MM-DD) *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = dueDateError,
|
||||
supportingText = if (dueDateError) {
|
||||
{ Text("Due date is required (format: YYYY-MM-DD)") }
|
||||
} else {
|
||||
{ Text("Format: YYYY-MM-DD") }
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Priority
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showPriorityDropdown,
|
||||
onExpandedChange = { showPriorityDropdown = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = priorities.find { it.name == priority.name }?.displayName ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text("Priority") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showPriorityDropdown) },
|
||||
enabled = priorities.isNotEmpty()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = showPriorityDropdown,
|
||||
onDismissRequest = { showPriorityDropdown = false }
|
||||
) {
|
||||
priorities.forEach { prio ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(prio.displayName) },
|
||||
onClick = {
|
||||
priority = prio
|
||||
showPriorityDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Estimated Cost
|
||||
OutlinedTextField(
|
||||
value = estimatedCost,
|
||||
onValueChange = { estimatedCost = it },
|
||||
label = { Text("Estimated Cost") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
prefix = { Text("$") },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Error message display
|
||||
if (errorMessage != null) {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
// Validation
|
||||
var hasError = false
|
||||
|
||||
if (selectedResidenceId == 0) {
|
||||
residenceError = true
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (title.isBlank()) {
|
||||
titleError = true
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (dueDate.isBlank() || !isValidDateFormat(dueDate)) {
|
||||
dueDateError = true
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (!hasError) {
|
||||
onCreate(
|
||||
TaskCreateRequest(
|
||||
residence = selectedResidenceId,
|
||||
title = title,
|
||||
description = description.ifBlank { null },
|
||||
category = category.id,
|
||||
frequency = frequency.id,
|
||||
intervalDays = intervalDays.toIntOrNull(),
|
||||
priority = priority.id,
|
||||
status = null,
|
||||
dueDate = dueDate,
|
||||
estimatedCost = estimatedCost.ifBlank { null }
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = !isLoading
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Create Task")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
AddTaskDialog(
|
||||
residenceId = null,
|
||||
residencesResponse = residencesResponse,
|
||||
onDismiss = onDismiss,
|
||||
onCreate = onCreate,
|
||||
isLoading = isLoading,
|
||||
errorMessage = errorMessage
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to validate date format
|
||||
private fun isValidDateFormat(date: String): Boolean {
|
||||
val datePattern = Regex("^\\d{4}-\\d{2}-\\d{2}$")
|
||||
return datePattern.matches(date)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
package com.mycrib.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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 com.mycrib.repository.LookupsRepository
|
||||
import com.mycrib.shared.models.MyResidencesResponse
|
||||
import com.mycrib.shared.models.TaskCategory
|
||||
import com.mycrib.shared.models.TaskCreateRequest
|
||||
import com.mycrib.shared.models.TaskFrequency
|
||||
import com.mycrib.shared.models.TaskPriority
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddTaskDialog(
|
||||
residenceId: Int? = null,
|
||||
residencesResponse: MyResidencesResponse? = null,
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (TaskCreateRequest) -> Unit,
|
||||
isLoading: Boolean = false,
|
||||
errorMessage: String? = null
|
||||
) {
|
||||
// Determine if we need residence selection
|
||||
val needsResidenceSelection = residenceId == null
|
||||
|
||||
var title by remember { mutableStateOf("") }
|
||||
var description by remember { mutableStateOf("") }
|
||||
var intervalDays by remember { mutableStateOf("") }
|
||||
var dueDate by remember { mutableStateOf("") }
|
||||
var estimatedCost by remember { mutableStateOf("") }
|
||||
|
||||
var selectedResidenceId by remember {
|
||||
mutableStateOf(
|
||||
residenceId ?: residencesResponse?.residences?.firstOrNull()?.id ?: 0
|
||||
)
|
||||
}
|
||||
var category by remember { mutableStateOf(TaskCategory(id = 0, name = "")) }
|
||||
var frequency by remember { mutableStateOf(TaskFrequency(
|
||||
id = 0, name = "", lookupName = "", displayName = "",
|
||||
daySpan = 0,
|
||||
notifyDays = 0
|
||||
)) }
|
||||
var priority by remember { mutableStateOf(TaskPriority(id = 0, name = "", displayName = "")) }
|
||||
|
||||
var showResidenceDropdown by remember { mutableStateOf(false) }
|
||||
var showFrequencyDropdown by remember { mutableStateOf(false) }
|
||||
var showPriorityDropdown by remember { mutableStateOf(false) }
|
||||
var showCategoryDropdown by remember { mutableStateOf(false) }
|
||||
|
||||
var titleError by remember { mutableStateOf(false) }
|
||||
var categoryError by remember { mutableStateOf(false) }
|
||||
var dueDateError by remember { mutableStateOf(false) }
|
||||
var residenceError by remember { mutableStateOf(false) }
|
||||
|
||||
// Get data from LookupsRepository
|
||||
val frequencies by LookupsRepository.taskFrequencies.collectAsState()
|
||||
val priorities by LookupsRepository.taskPriorities.collectAsState()
|
||||
val categories by LookupsRepository.taskCategories.collectAsState()
|
||||
|
||||
// Set defaults when data loads
|
||||
LaunchedEffect(frequencies) {
|
||||
if (frequencies.isNotEmpty()) {
|
||||
frequency = frequencies.first()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(priorities) {
|
||||
if (priorities.isNotEmpty()) {
|
||||
priority = priorities.first()
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Add New Task") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Residence Selector (only if residenceId is null)
|
||||
if (needsResidenceSelection && residencesResponse != null) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showResidenceDropdown,
|
||||
onExpandedChange = { showResidenceDropdown = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = residencesResponse.residences.find { it.id == selectedResidenceId }?.name ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text("Property *") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
isError = residenceError,
|
||||
supportingText = if (residenceError) {
|
||||
{ Text("Property is required") }
|
||||
} else null,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showResidenceDropdown) },
|
||||
readOnly = true,
|
||||
enabled = residencesResponse.residences.isNotEmpty()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = showResidenceDropdown,
|
||||
onDismissRequest = { showResidenceDropdown = false }
|
||||
) {
|
||||
residencesResponse.residences.forEach { residence ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(residence.name) },
|
||||
onClick = {
|
||||
selectedResidenceId = residence.id
|
||||
residenceError = false
|
||||
showResidenceDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = {
|
||||
title = it
|
||||
titleError = false
|
||||
},
|
||||
label = { Text("Title *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = titleError,
|
||||
supportingText = if (titleError) {
|
||||
{ Text("Title is required") }
|
||||
} else null,
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Description
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Description") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
maxLines = 4
|
||||
)
|
||||
|
||||
// Category
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showCategoryDropdown,
|
||||
onExpandedChange = { showCategoryDropdown = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = categories.find { it == category }?.name ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text("Category *") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
isError = categoryError,
|
||||
supportingText = if (categoryError) {
|
||||
{ Text("Category is required") }
|
||||
} else null,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showCategoryDropdown) },
|
||||
readOnly = false,
|
||||
enabled = categories.isNotEmpty()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = showCategoryDropdown,
|
||||
onDismissRequest = { showCategoryDropdown = false }
|
||||
) {
|
||||
categories.forEach { cat ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(cat.name) },
|
||||
onClick = {
|
||||
category = cat
|
||||
categoryError = false
|
||||
showCategoryDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Frequency
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showFrequencyDropdown,
|
||||
onExpandedChange = { showFrequencyDropdown = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = frequencies.find { it == frequency }?.displayName ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text("Frequency") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showFrequencyDropdown) },
|
||||
enabled = frequencies.isNotEmpty()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = showFrequencyDropdown,
|
||||
onDismissRequest = { showFrequencyDropdown = false }
|
||||
) {
|
||||
frequencies.forEach { freq ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(freq.displayName) },
|
||||
onClick = {
|
||||
frequency = freq
|
||||
showFrequencyDropdown = false
|
||||
// Clear interval days if frequency is "once"
|
||||
if (freq.name == "once") {
|
||||
intervalDays = ""
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interval Days (only for recurring tasks)
|
||||
if (frequency.name != "once") {
|
||||
OutlinedTextField(
|
||||
value = intervalDays,
|
||||
onValueChange = { intervalDays = it.filter { char -> char.isDigit() } },
|
||||
label = { Text("Interval Days (optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
supportingText = { Text("Override default frequency interval") },
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
// Due Date
|
||||
OutlinedTextField(
|
||||
value = dueDate,
|
||||
onValueChange = {
|
||||
dueDate = it
|
||||
dueDateError = false
|
||||
},
|
||||
label = { Text("Due Date (YYYY-MM-DD) *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = dueDateError,
|
||||
supportingText = if (dueDateError) {
|
||||
{ Text("Due date is required (format: YYYY-MM-DD)") }
|
||||
} else {
|
||||
{ Text("Format: YYYY-MM-DD") }
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Priority
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showPriorityDropdown,
|
||||
onExpandedChange = { showPriorityDropdown = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = priorities.find { it.name == priority.name }?.displayName ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text("Priority") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showPriorityDropdown) },
|
||||
enabled = priorities.isNotEmpty()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = showPriorityDropdown,
|
||||
onDismissRequest = { showPriorityDropdown = false }
|
||||
) {
|
||||
priorities.forEach { prio ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(prio.displayName) },
|
||||
onClick = {
|
||||
priority = prio
|
||||
showPriorityDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Estimated Cost
|
||||
OutlinedTextField(
|
||||
value = estimatedCost,
|
||||
onValueChange = { estimatedCost = it },
|
||||
label = { Text("Estimated Cost") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
prefix = { Text("$") },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Error message display
|
||||
if (errorMessage != null) {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
// Validation
|
||||
var hasError = false
|
||||
|
||||
if (needsResidenceSelection && selectedResidenceId == 0) {
|
||||
residenceError = true
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (title.isBlank()) {
|
||||
titleError = true
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (dueDate.isBlank() || !isValidDateFormat(dueDate)) {
|
||||
dueDateError = true
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (!hasError) {
|
||||
onCreate(
|
||||
TaskCreateRequest(
|
||||
residence = selectedResidenceId,
|
||||
title = title,
|
||||
description = description.ifBlank { null },
|
||||
category = category.id,
|
||||
frequency = frequency.id,
|
||||
intervalDays = intervalDays.toIntOrNull(),
|
||||
priority = priority.id,
|
||||
status = null,
|
||||
dueDate = dueDate,
|
||||
estimatedCost = estimatedCost.ifBlank { null }
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = !isLoading
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Create Task")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to validate date format
|
||||
private fun isValidDateFormat(date: String): Boolean {
|
||||
val datePattern = Regex("^\\d{4}-\\d{2}-\\d{2}$")
|
||||
return datePattern.matches(date)
|
||||
}
|
||||
@@ -1,564 +1,26 @@
|
||||
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.compose.runtime.Composable
|
||||
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
|
||||
initialDocumentType: String = "other",
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DocumentFormScreen(
|
||||
residenceId = residenceId,
|
||||
existingDocumentId = null,
|
||||
initialDocumentType = initialDocumentType,
|
||||
onNavigateBack = onNavigateBack,
|
||||
onSuccess = onDocumentCreated,
|
||||
documentViewModel = documentViewModel,
|
||||
residenceViewModel = residenceViewModel
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,364 +1,19 @@
|
||||
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.ArrowBack
|
||||
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.compose.runtime.Composable
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mycrib.android.viewmodel.ResidenceViewModel
|
||||
import com.mycrib.repository.LookupsRepository
|
||||
import com.mycrib.shared.models.ResidenceCreateRequest
|
||||
import com.mycrib.shared.models.ResidenceType
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddResidenceScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onResidenceCreated: () -> Unit,
|
||||
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var propertyType by remember { mutableStateOf(ResidenceType(0, "placeHolder")) }
|
||||
var streetAddress by remember { mutableStateOf("") }
|
||||
var apartmentUnit by remember { mutableStateOf("") }
|
||||
var city by remember { mutableStateOf("") }
|
||||
var stateProvince by remember { mutableStateOf("") }
|
||||
var postalCode by remember { mutableStateOf("") }
|
||||
var country by remember { mutableStateOf("USA") }
|
||||
var bedrooms by remember { mutableStateOf("") }
|
||||
var bathrooms by remember { mutableStateOf("") }
|
||||
var squareFootage by remember { mutableStateOf("") }
|
||||
var lotSize by remember { mutableStateOf("") }
|
||||
var yearBuilt by remember { mutableStateOf("") }
|
||||
var description by remember { mutableStateOf("") }
|
||||
var isPrimary by remember { mutableStateOf(false) }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val createState by viewModel.createResidenceState.collectAsState()
|
||||
val propertyTypes by LookupsRepository.residenceTypes.collectAsState()
|
||||
|
||||
// Validation errors
|
||||
var nameError by remember { mutableStateOf("") }
|
||||
var streetAddressError by remember { mutableStateOf("") }
|
||||
var cityError by remember { mutableStateOf("") }
|
||||
var stateProvinceError by remember { mutableStateOf("") }
|
||||
var postalCodeError by remember { mutableStateOf("") }
|
||||
|
||||
// Handle create state changes
|
||||
LaunchedEffect(createState) {
|
||||
when (createState) {
|
||||
is ApiResult.Success -> {
|
||||
viewModel.resetCreateState()
|
||||
onResidenceCreated()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// Set default property type if not set and types are loaded
|
||||
LaunchedEffect(propertyTypes) {
|
||||
if (propertyTypes.isNotEmpty()) {
|
||||
propertyType = propertyTypes.first()
|
||||
}
|
||||
}
|
||||
|
||||
fun validateForm(): Boolean {
|
||||
var isValid = true
|
||||
|
||||
if (name.isBlank()) {
|
||||
nameError = "Name is required"
|
||||
isValid = false
|
||||
} else {
|
||||
nameError = ""
|
||||
}
|
||||
|
||||
if (streetAddress.isBlank()) {
|
||||
streetAddressError = "Street address is required"
|
||||
isValid = false
|
||||
} else {
|
||||
streetAddressError = ""
|
||||
}
|
||||
|
||||
if (city.isBlank()) {
|
||||
cityError = "City is required"
|
||||
isValid = false
|
||||
} else {
|
||||
cityError = ""
|
||||
}
|
||||
|
||||
if (stateProvince.isBlank()) {
|
||||
stateProvinceError = "State/Province is required"
|
||||
isValid = false
|
||||
} else {
|
||||
stateProvinceError = ""
|
||||
}
|
||||
|
||||
if (postalCode.isBlank()) {
|
||||
postalCodeError = "Postal code is required"
|
||||
isValid = false
|
||||
} else {
|
||||
postalCodeError = ""
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Add Residence") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Required fields section
|
||||
Text(
|
||||
text = "Required Information",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Property Name *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = nameError.isNotEmpty(),
|
||||
supportingText = if (nameError.isNotEmpty()) {
|
||||
{ Text(nameError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = propertyTypes.find { it.name == propertyType.name }?.name?.replaceFirstChar { it.uppercase() } ?: "",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Property Type *") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
enabled = propertyTypes.isNotEmpty()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
propertyTypes.forEach { type ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(type.name.replaceFirstChar { it.uppercase() }) },
|
||||
onClick = {
|
||||
propertyType = type
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = streetAddress,
|
||||
onValueChange = { streetAddress = it },
|
||||
label = { Text("Street Address *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = streetAddressError.isNotEmpty(),
|
||||
supportingText = if (streetAddressError.isNotEmpty()) {
|
||||
{ Text(streetAddressError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = apartmentUnit,
|
||||
onValueChange = { apartmentUnit = it },
|
||||
label = { Text("Apartment/Unit #") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = city,
|
||||
onValueChange = { city = it },
|
||||
label = { Text("City *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = cityError.isNotEmpty(),
|
||||
supportingText = if (cityError.isNotEmpty()) {
|
||||
{ Text(cityError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = stateProvince,
|
||||
onValueChange = { stateProvince = it },
|
||||
label = { Text("State/Province *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = stateProvinceError.isNotEmpty(),
|
||||
supportingText = if (stateProvinceError.isNotEmpty()) {
|
||||
{ Text(stateProvinceError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = postalCode,
|
||||
onValueChange = { postalCode = it },
|
||||
label = { Text("Postal Code *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = postalCodeError.isNotEmpty(),
|
||||
supportingText = if (postalCodeError.isNotEmpty()) {
|
||||
{ Text(postalCodeError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = country,
|
||||
onValueChange = { country = it },
|
||||
label = { Text("Country") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Optional fields section
|
||||
Divider()
|
||||
Text(
|
||||
text = "Optional Details",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = bedrooms,
|
||||
onValueChange = { bedrooms = it.filter { char -> char.isDigit() } },
|
||||
label = { Text("Bedrooms") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = bathrooms,
|
||||
onValueChange = { bathrooms = it },
|
||||
label = { Text("Bathrooms") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = squareFootage,
|
||||
onValueChange = { squareFootage = it.filter { char -> char.isDigit() } },
|
||||
label = { Text("Square Footage") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = lotSize,
|
||||
onValueChange = { lotSize = it },
|
||||
label = { Text("Lot Size (acres)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = yearBuilt,
|
||||
onValueChange = { yearBuilt = it.filter { char -> char.isDigit() } },
|
||||
label = { Text("Year Built") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Description") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("Primary Residence")
|
||||
Switch(
|
||||
checked = isPrimary,
|
||||
onCheckedChange = { isPrimary = it }
|
||||
)
|
||||
}
|
||||
|
||||
// Error message
|
||||
if (createState is ApiResult.Error) {
|
||||
Text(
|
||||
text = (createState as ApiResult.Error).message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
// Submit button
|
||||
Button(
|
||||
onClick = {
|
||||
if (validateForm()) {
|
||||
viewModel.createResidence(
|
||||
ResidenceCreateRequest(
|
||||
name = name,
|
||||
propertyType = propertyType.id,
|
||||
streetAddress = streetAddress,
|
||||
apartmentUnit = apartmentUnit.ifBlank { null },
|
||||
city = city,
|
||||
stateProvince = stateProvince,
|
||||
postalCode = postalCode,
|
||||
country = country,
|
||||
bedrooms = bedrooms.toIntOrNull(),
|
||||
bathrooms = bathrooms.toFloatOrNull(),
|
||||
squareFootage = squareFootage.toIntOrNull(),
|
||||
lotSize = lotSize.toFloatOrNull(),
|
||||
yearBuilt = yearBuilt.toIntOrNull(),
|
||||
description = description.ifBlank { null },
|
||||
isPrimary = isPrimary
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = validateForm()
|
||||
) {
|
||||
if (createState is ApiResult.Loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Create Residence")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
ResidenceFormScreen(
|
||||
existingResidence = null,
|
||||
onNavigateBack = onNavigateBack,
|
||||
onSuccess = onResidenceCreated,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
package com.mycrib.android.ui.screens
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil3.compose.AsyncImage
|
||||
import com.mycrib.android.viewmodel.DocumentViewModel
|
||||
import com.mycrib.android.viewmodel.ResidenceViewModel
|
||||
import com.mycrib.shared.models.*
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
import com.mycrib.platform.ImageData
|
||||
import com.mycrib.platform.rememberImagePicker
|
||||
import com.mycrib.platform.rememberCameraPicker
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DocumentFormScreen(
|
||||
residenceId: Int? = null,
|
||||
existingDocumentId: Int? = null,
|
||||
initialDocumentType: String = "other",
|
||||
onNavigateBack: () -> Unit,
|
||||
onSuccess: () -> Unit,
|
||||
documentViewModel: DocumentViewModel = viewModel { DocumentViewModel() },
|
||||
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
|
||||
) {
|
||||
val isEditMode = existingDocumentId != null
|
||||
val needsResidenceSelection = residenceId == null || residenceId == -1
|
||||
|
||||
// State
|
||||
var selectedResidence by remember { mutableStateOf<Residence?>(null) }
|
||||
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("") }
|
||||
var isActive by remember { mutableStateOf(true) }
|
||||
var selectedImages by remember { mutableStateOf<List<ImageData>>(emptyList()) }
|
||||
var existingImageUrls by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
|
||||
// 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 residencesState by residenceViewModel.residencesState.collectAsState()
|
||||
val documentDetailState by documentViewModel.documentDetailState.collectAsState()
|
||||
val operationState by if (isEditMode) {
|
||||
documentViewModel.updateState.collectAsState()
|
||||
} else {
|
||||
documentViewModel.createState.collectAsState()
|
||||
}
|
||||
|
||||
val isWarranty = selectedDocumentType == "warranty"
|
||||
val maxImages = if (isEditMode) 10 else 5
|
||||
|
||||
// Image pickers
|
||||
val imagePicker = rememberImagePicker { images ->
|
||||
selectedImages = if (selectedImages.size + images.size <= maxImages) {
|
||||
selectedImages + images
|
||||
} else {
|
||||
selectedImages + images.take(maxImages - selectedImages.size)
|
||||
}
|
||||
}
|
||||
|
||||
val cameraPicker = rememberCameraPicker { image ->
|
||||
if (selectedImages.size < maxImages) {
|
||||
selectedImages = selectedImages + image
|
||||
}
|
||||
}
|
||||
|
||||
// Load residences if needed
|
||||
LaunchedEffect(needsResidenceSelection) {
|
||||
if (needsResidenceSelection) {
|
||||
residenceViewModel.loadResidences()
|
||||
}
|
||||
}
|
||||
|
||||
// Load existing document for edit mode
|
||||
LaunchedEffect(existingDocumentId) {
|
||||
if (existingDocumentId != null) {
|
||||
documentViewModel.loadDocumentDetail(existingDocumentId)
|
||||
}
|
||||
}
|
||||
|
||||
// Populate form from existing document
|
||||
LaunchedEffect(documentDetailState) {
|
||||
if (isEditMode && documentDetailState is ApiResult.Success) {
|
||||
val document = (documentDetailState as ApiResult.Success<Document>).data
|
||||
title = document.title
|
||||
selectedDocumentType = document.documentType
|
||||
description = document.description ?: ""
|
||||
selectedCategory = document.category
|
||||
tags = document.tags ?: ""
|
||||
notes = document.notes ?: ""
|
||||
isActive = document.isActive
|
||||
existingImageUrls = document.images.map { it.imageUrl }
|
||||
|
||||
// Warranty fields
|
||||
itemName = document.itemName ?: ""
|
||||
modelNumber = document.modelNumber ?: ""
|
||||
serialNumber = document.serialNumber ?: ""
|
||||
provider = document.provider ?: ""
|
||||
providerContact = document.providerContact ?: ""
|
||||
claimPhone = document.claimPhone ?: ""
|
||||
claimEmail = document.claimEmail ?: ""
|
||||
claimWebsite = document.claimWebsite ?: ""
|
||||
purchaseDate = document.purchaseDate ?: ""
|
||||
startDate = document.startDate ?: ""
|
||||
endDate = document.endDate ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
// Handle success
|
||||
LaunchedEffect(operationState) {
|
||||
if (operationState is ApiResult.Success) {
|
||||
if (isEditMode) {
|
||||
documentViewModel.resetUpdateState()
|
||||
} else {
|
||||
documentViewModel.resetCreateState()
|
||||
}
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
when {
|
||||
isEditMode && isWarranty -> "Edit Warranty"
|
||||
isEditMode -> "Edit Document"
|
||||
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)
|
||||
) {
|
||||
// Loading state for edit mode
|
||||
if (isEditMode && documentDetailState is ApiResult.Loading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
// 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()
|
||||
)
|
||||
|
||||
// Warranty-specific fields
|
||||
if (isWarranty) {
|
||||
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()
|
||||
)
|
||||
|
||||
// Active toggle (edit mode only)
|
||||
if (isEditMode) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Active")
|
||||
Switch(
|
||||
checked = isActive,
|
||||
onCheckedChange = { isActive = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Existing images (edit mode only)
|
||||
if (isEditMode && existingImageUrls.isNotEmpty()) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Existing Photos (${existingImageUrls.size})",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
existingImageUrls.forEach { url ->
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
"${if (isEditMode) "New " else ""}Photos (${selectedImages.size}/$maxImages)",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = { cameraPicker() },
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = selectedImages.size < maxImages
|
||||
) {
|
||||
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 < maxImages
|
||||
) {
|
||||
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 = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = 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 (operationState is ApiResult.Error) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
(operationState 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
|
||||
} else {
|
||||
selectedResidence!!.id
|
||||
}
|
||||
} else {
|
||||
residenceId ?: -1
|
||||
}
|
||||
|
||||
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) {
|
||||
if (isEditMode && existingDocumentId != null) {
|
||||
documentViewModel.updateDocument(
|
||||
id = existingDocumentId,
|
||||
title = title,
|
||||
documentType = selectedDocumentType,
|
||||
description = description.ifBlank { null },
|
||||
category = selectedCategory,
|
||||
tags = tags.ifBlank { null },
|
||||
notes = notes.ifBlank { null },
|
||||
isActive = isActive,
|
||||
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 = selectedImages
|
||||
)
|
||||
} else {
|
||||
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,
|
||||
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 = selectedImages
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = operationState !is ApiResult.Loading
|
||||
) {
|
||||
if (operationState is ApiResult.Loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
when {
|
||||
isEditMode && isWarranty -> "Update Warranty"
|
||||
isEditMode -> "Update Document"
|
||||
isWarranty -> "Add Warranty"
|
||||
else -> "Add Document"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,676 +1,21 @@
|
||||
package com.mycrib.android.ui.screens
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil3.compose.AsyncImage
|
||||
import com.mycrib.android.viewmodel.DocumentViewModel
|
||||
import com.mycrib.shared.models.*
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
import com.mycrib.platform.ImageData
|
||||
import com.mycrib.platform.rememberImagePicker
|
||||
import com.mycrib.platform.rememberCameraPicker
|
||||
import com.mycrib.android.ui.components.documents.ErrorState
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditDocumentScreen(
|
||||
documentId: Int,
|
||||
onNavigateBack: () -> Unit,
|
||||
documentViewModel: DocumentViewModel = viewModel { DocumentViewModel() }
|
||||
) {
|
||||
val documentDetailState by documentViewModel.documentDetailState.collectAsState()
|
||||
val updateState by documentViewModel.updateState.collectAsState()
|
||||
|
||||
// Form state
|
||||
var title by remember { mutableStateOf("") }
|
||||
var documentType by remember { mutableStateOf("other") }
|
||||
var category by remember { mutableStateOf<String?>(null) }
|
||||
var description by remember { mutableStateOf("") }
|
||||
var tags by remember { mutableStateOf("") }
|
||||
var notes by remember { mutableStateOf("") }
|
||||
var isActive by remember { mutableStateOf(true) }
|
||||
|
||||
// Warranty-specific fields
|
||||
var itemName by remember { mutableStateOf("") }
|
||||
var modelNumber by remember { mutableStateOf("") }
|
||||
var serialNumber by remember { mutableStateOf("") }
|
||||
var provider by remember { mutableStateOf("") }
|
||||
var providerContact by remember { mutableStateOf("") }
|
||||
var claimPhone by remember { mutableStateOf("") }
|
||||
var claimEmail by remember { mutableStateOf("") }
|
||||
var claimWebsite by remember { mutableStateOf("") }
|
||||
var purchaseDate by remember { mutableStateOf("") }
|
||||
var startDate by remember { mutableStateOf("") }
|
||||
var endDate by remember { mutableStateOf("") }
|
||||
|
||||
var showCategoryMenu by remember { mutableStateOf(false) }
|
||||
var showTypeMenu by remember { mutableStateOf(false) }
|
||||
var showSnackbar by remember { mutableStateOf(false) }
|
||||
var snackbarMessage by remember { mutableStateOf("") }
|
||||
|
||||
// Image management
|
||||
var existingImages by remember { mutableStateOf<List<DocumentImage>>(emptyList()) }
|
||||
var newImages by remember { mutableStateOf<List<ImageData>>(emptyList()) }
|
||||
var imagesToDelete by remember { mutableStateOf<Set<Int>>(emptySet()) }
|
||||
|
||||
val imagePicker = rememberImagePicker { images ->
|
||||
// Limit total images to 10
|
||||
val totalCount = existingImages.size - imagesToDelete.size + newImages.size
|
||||
newImages = if (totalCount + images.size <= 10) {
|
||||
newImages + images
|
||||
} else {
|
||||
newImages + images.take(10 - totalCount)
|
||||
}
|
||||
}
|
||||
|
||||
val cameraPicker = rememberCameraPicker { image ->
|
||||
val totalCount = existingImages.size - imagesToDelete.size + newImages.size
|
||||
if (totalCount < 10) {
|
||||
newImages = newImages + image
|
||||
}
|
||||
}
|
||||
|
||||
// Load document details on first composition
|
||||
LaunchedEffect(documentId) {
|
||||
documentViewModel.loadDocumentDetail(documentId)
|
||||
}
|
||||
|
||||
// Populate form when document loads
|
||||
LaunchedEffect(documentDetailState) {
|
||||
if (documentDetailState is ApiResult.Success) {
|
||||
val doc = (documentDetailState as ApiResult.Success<Document>).data
|
||||
title = doc.title
|
||||
documentType = doc.documentType
|
||||
category = doc.category
|
||||
description = doc.description ?: ""
|
||||
tags = doc.tags ?: ""
|
||||
notes = doc.notes ?: ""
|
||||
isActive = doc.isActive
|
||||
|
||||
// Warranty fields
|
||||
itemName = doc.itemName ?: ""
|
||||
modelNumber = doc.modelNumber ?: ""
|
||||
serialNumber = doc.serialNumber ?: ""
|
||||
provider = doc.provider ?: ""
|
||||
providerContact = doc.providerContact ?: ""
|
||||
claimPhone = doc.claimPhone ?: ""
|
||||
claimEmail = doc.claimEmail ?: ""
|
||||
claimWebsite = doc.claimWebsite ?: ""
|
||||
purchaseDate = doc.purchaseDate ?: ""
|
||||
startDate = doc.startDate ?: ""
|
||||
endDate = doc.endDate ?: ""
|
||||
|
||||
// Load existing images
|
||||
existingImages = doc.images
|
||||
}
|
||||
}
|
||||
|
||||
// Handle update result
|
||||
LaunchedEffect(updateState) {
|
||||
when (updateState) {
|
||||
is ApiResult.Success -> {
|
||||
snackbarMessage = "Document updated successfully"
|
||||
showSnackbar = true
|
||||
// Wait a bit before resetting so snackbar can be seen
|
||||
kotlinx.coroutines.delay(500)
|
||||
documentViewModel.resetUpdateState()
|
||||
// Refresh document details
|
||||
documentViewModel.loadDocumentDetail(documentId)
|
||||
// Clear new images after successful upload
|
||||
newImages = emptyList()
|
||||
imagesToDelete = emptySet()
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
snackbarMessage = (updateState as ApiResult.Error).message
|
||||
showSnackbar = true
|
||||
documentViewModel.resetUpdateState()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Edit Document", fontWeight = FontWeight.Bold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
if (showSnackbar) {
|
||||
Snackbar(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
action = {
|
||||
TextButton(onClick = { showSnackbar = false }) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(snackbarMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
when (documentDetailState) {
|
||||
is ApiResult.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
is ApiResult.Success -> {
|
||||
val document = (documentDetailState as ApiResult.Success<Document>).data
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Document Type (Read-only for editing)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
"Document Type",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
DocumentType.fromValue(documentType).displayName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
"Document type cannot be changed",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Basic Information
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Basic Information",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
label = { Text("Title *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Category (only for warranties)
|
||||
if (documentType == "warranty") {
|
||||
Box {
|
||||
OutlinedTextField(
|
||||
value = category?.let { DocumentCategory.fromValue(it).displayName } ?: "Select category",
|
||||
onValueChange = {},
|
||||
label = { Text("Category") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
readOnly = true,
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showCategoryMenu = true }) {
|
||||
Icon(Icons.Default.ArrowDropDown, "Select")
|
||||
}
|
||||
}
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = showCategoryMenu,
|
||||
onDismissRequest = { showCategoryMenu = false }
|
||||
) {
|
||||
DocumentCategory.values().forEach { cat ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(cat.displayName) },
|
||||
onClick = {
|
||||
category = cat.value
|
||||
showCategoryMenu = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Description") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Warranty/Item Details (only for warranties)
|
||||
if (documentType == "warranty") {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Item Details",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = itemName,
|
||||
onValueChange = { itemName = it },
|
||||
label = { Text("Item Name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = modelNumber,
|
||||
onValueChange = { modelNumber = it },
|
||||
label = { Text("Model Number") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = serialNumber,
|
||||
onValueChange = { serialNumber = it },
|
||||
label = { Text("Serial Number") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = provider,
|
||||
onValueChange = { provider = it },
|
||||
label = { Text("Provider/Manufacturer") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = providerContact,
|
||||
onValueChange = { providerContact = it },
|
||||
label = { Text("Provider Contact") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Claim Information
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Claim Information",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = claimPhone,
|
||||
onValueChange = { claimPhone = it },
|
||||
label = { Text("Claim Phone") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = claimEmail,
|
||||
onValueChange = { claimEmail = it },
|
||||
label = { Text("Claim Email") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = claimWebsite,
|
||||
onValueChange = { claimWebsite = it },
|
||||
label = { Text("Claim Website") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Dates
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Important Dates",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = purchaseDate,
|
||||
onValueChange = { purchaseDate = it },
|
||||
label = { Text("Purchase Date (YYYY-MM-DD)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = startDate,
|
||||
onValueChange = { startDate = it },
|
||||
label = { Text("Start Date (YYYY-MM-DD)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = endDate,
|
||||
onValueChange = { endDate = it },
|
||||
label = { Text("End Date (YYYY-MM-DD)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Image Management
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
val totalImages = existingImages.size - imagesToDelete.size + newImages.size
|
||||
Text(
|
||||
"Photos ($totalImages/10)",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { cameraPicker() },
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = totalImages < 10
|
||||
) {
|
||||
Icon(Icons.Default.CameraAlt, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Camera")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { imagePicker() },
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = totalImages < 10
|
||||
) {
|
||||
Icon(Icons.Default.Photo, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Gallery")
|
||||
}
|
||||
}
|
||||
|
||||
// Display existing images
|
||||
if (existingImages.isNotEmpty()) {
|
||||
Text(
|
||||
"Existing Images",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
existingImages.forEach { image ->
|
||||
if (image.id !in imagesToDelete) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outline,
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
// Image thumbnail
|
||||
image.imageUrl?.let { url ->
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} ?: Icon(
|
||||
Icons.Default.Image,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(60.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Text(
|
||||
image.caption ?: "Image ${image.id}",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
image.id?.let {
|
||||
imagesToDelete = imagesToDelete + it
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove image",
|
||||
tint = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Display new images to be uploaded
|
||||
if (newImages.isNotEmpty()) {
|
||||
Text(
|
||||
"New Images",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
newImages.forEachIndexed { index, image ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Image,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
"New Image ${index + 1}",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
newImages = newImages.filter { it != image }
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove image",
|
||||
tint = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional Information
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Additional Information",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = tags,
|
||||
onValueChange = { tags = it },
|
||||
label = { Text("Tags (comma-separated)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
label = { Text("Notes") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Active", style = MaterialTheme.typography.bodyLarge)
|
||||
Switch(
|
||||
checked = isActive,
|
||||
onCheckedChange = { isActive = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save Button
|
||||
Button(
|
||||
onClick = {
|
||||
if (title.isNotBlank()) {
|
||||
// First, delete any images marked for deletion
|
||||
imagesToDelete.forEach { imageId ->
|
||||
documentViewModel.deleteDocumentImage(imageId)
|
||||
}
|
||||
|
||||
// Then update the document with new images
|
||||
documentViewModel.updateDocument(
|
||||
id = documentId,
|
||||
title = title,
|
||||
documentType = documentType,
|
||||
description = description.ifBlank { null },
|
||||
category = category,
|
||||
tags = tags.ifBlank { null },
|
||||
notes = notes.ifBlank { null },
|
||||
isActive = isActive,
|
||||
itemName = itemName.ifBlank { null },
|
||||
modelNumber = modelNumber.ifBlank { null },
|
||||
serialNumber = serialNumber.ifBlank { null },
|
||||
provider = provider.ifBlank { null },
|
||||
providerContact = providerContact.ifBlank { null },
|
||||
claimPhone = claimPhone.ifBlank { null },
|
||||
claimEmail = claimEmail.ifBlank { null },
|
||||
claimWebsite = claimWebsite.ifBlank { null },
|
||||
purchaseDate = purchaseDate.ifBlank { null },
|
||||
startDate = startDate.ifBlank { null },
|
||||
endDate = endDate.ifBlank { null },
|
||||
images = newImages
|
||||
)
|
||||
} else {
|
||||
snackbarMessage = "Title is required"
|
||||
showSnackbar = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = updateState !is ApiResult.Loading
|
||||
) {
|
||||
if (updateState is ApiResult.Loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = Color.White
|
||||
)
|
||||
} else {
|
||||
Text("Save Changes")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
ErrorState(
|
||||
message = (documentDetailState as ApiResult.Error).message,
|
||||
onRetry = { documentViewModel.loadDocumentDetail(documentId) }
|
||||
)
|
||||
}
|
||||
is ApiResult.Idle -> {}
|
||||
}
|
||||
}
|
||||
DocumentFormScreen(
|
||||
residenceId = null,
|
||||
existingDocumentId = documentId,
|
||||
initialDocumentType = "other",
|
||||
onNavigateBack = onNavigateBack,
|
||||
onSuccess = onNavigateBack,
|
||||
documentViewModel = documentViewModel
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
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.ArrowBack
|
||||
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.compose.runtime.Composable
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mycrib.android.viewmodel.ResidenceViewModel
|
||||
import com.mycrib.repository.LookupsRepository
|
||||
import com.mycrib.shared.models.Residence
|
||||
import com.mycrib.shared.models.ResidenceCreateRequest
|
||||
import com.mycrib.shared.models.ResidenceType
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditResidenceScreen(
|
||||
residence: Residence,
|
||||
@@ -27,343 +12,10 @@ fun EditResidenceScreen(
|
||||
onResidenceUpdated: () -> Unit,
|
||||
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
|
||||
) {
|
||||
var name by remember { mutableStateOf(residence.name) }
|
||||
var propertyType by remember { mutableStateOf<ResidenceType?>(null) }
|
||||
var streetAddress by remember { mutableStateOf(residence.streetAddress) }
|
||||
var apartmentUnit by remember { mutableStateOf(residence.apartmentUnit ?: "") }
|
||||
var city by remember { mutableStateOf(residence.city) }
|
||||
var stateProvince by remember { mutableStateOf(residence.stateProvince) }
|
||||
var postalCode by remember { mutableStateOf(residence.postalCode) }
|
||||
var country by remember { mutableStateOf(residence.country) }
|
||||
var bedrooms by remember { mutableStateOf(residence.bedrooms?.toString() ?: "") }
|
||||
var bathrooms by remember { mutableStateOf(residence.bathrooms?.toString() ?: "") }
|
||||
var squareFootage by remember { mutableStateOf(residence.squareFootage?.toString() ?: "") }
|
||||
var lotSize by remember { mutableStateOf(residence.lotSize?.toString() ?: "") }
|
||||
var yearBuilt by remember { mutableStateOf(residence.yearBuilt?.toString() ?: "") }
|
||||
var description by remember { mutableStateOf(residence.description ?: "") }
|
||||
var isPrimary by remember { mutableStateOf(residence.isPrimary) }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val updateState by viewModel.updateResidenceState.collectAsState()
|
||||
val propertyTypes by LookupsRepository.residenceTypes.collectAsState()
|
||||
|
||||
// Validation errors
|
||||
var nameError by remember { mutableStateOf("") }
|
||||
var streetAddressError by remember { mutableStateOf("") }
|
||||
var cityError by remember { mutableStateOf("") }
|
||||
var stateProvinceError by remember { mutableStateOf("") }
|
||||
var postalCodeError by remember { mutableStateOf("") }
|
||||
|
||||
// Handle update state changes
|
||||
LaunchedEffect(updateState) {
|
||||
when (updateState) {
|
||||
is ApiResult.Success -> {
|
||||
viewModel.resetUpdateState()
|
||||
onResidenceUpdated()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// Set property type from residence when types are loaded
|
||||
LaunchedEffect(propertyTypes, residence) {
|
||||
if (propertyTypes.isNotEmpty() && propertyType == null) {
|
||||
propertyType = residence.propertyType.let { pt ->
|
||||
propertyTypes.find { it.id == pt.toInt() }
|
||||
} ?: propertyTypes.first()
|
||||
}
|
||||
}
|
||||
|
||||
fun validateForm(): Boolean {
|
||||
var isValid = true
|
||||
|
||||
if (name.isBlank()) {
|
||||
nameError = "Name is required"
|
||||
isValid = false
|
||||
} else {
|
||||
nameError = ""
|
||||
}
|
||||
|
||||
if (streetAddress.isBlank()) {
|
||||
streetAddressError = "Street address is required"
|
||||
isValid = false
|
||||
} else {
|
||||
streetAddressError = ""
|
||||
}
|
||||
|
||||
if (city.isBlank()) {
|
||||
cityError = "City is required"
|
||||
isValid = false
|
||||
} else {
|
||||
cityError = ""
|
||||
}
|
||||
|
||||
if (stateProvince.isBlank()) {
|
||||
stateProvinceError = "State/Province is required"
|
||||
isValid = false
|
||||
} else {
|
||||
stateProvinceError = ""
|
||||
}
|
||||
|
||||
if (postalCode.isBlank()) {
|
||||
postalCodeError = "Postal code is required"
|
||||
isValid = false
|
||||
} else {
|
||||
postalCodeError = ""
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Edit Residence") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Required fields section
|
||||
Text(
|
||||
text = "Required Information",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Property Name *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = nameError.isNotEmpty(),
|
||||
supportingText = if (nameError.isNotEmpty()) {
|
||||
{ Text(nameError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = propertyType?.name?.replaceFirstChar { it.uppercase() } ?: "",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Property Type *") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
enabled = propertyTypes.isNotEmpty()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
propertyTypes.forEach { type ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(type.name.replaceFirstChar { it.uppercase() }) },
|
||||
onClick = {
|
||||
propertyType = type
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = streetAddress,
|
||||
onValueChange = { streetAddress = it },
|
||||
label = { Text("Street Address *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = streetAddressError.isNotEmpty(),
|
||||
supportingText = if (streetAddressError.isNotEmpty()) {
|
||||
{ Text(streetAddressError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = apartmentUnit,
|
||||
onValueChange = { apartmentUnit = it },
|
||||
label = { Text("Apartment/Unit #") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = city,
|
||||
onValueChange = { city = it },
|
||||
label = { Text("City *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = cityError.isNotEmpty(),
|
||||
supportingText = if (cityError.isNotEmpty()) {
|
||||
{ Text(cityError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = stateProvince,
|
||||
onValueChange = { stateProvince = it },
|
||||
label = { Text("State/Province *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = stateProvinceError.isNotEmpty(),
|
||||
supportingText = if (stateProvinceError.isNotEmpty()) {
|
||||
{ Text(stateProvinceError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = postalCode,
|
||||
onValueChange = { postalCode = it },
|
||||
label = { Text("Postal Code *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = postalCodeError.isNotEmpty(),
|
||||
supportingText = if (postalCodeError.isNotEmpty()) {
|
||||
{ Text(postalCodeError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = country,
|
||||
onValueChange = { country = it },
|
||||
label = { Text("Country") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Optional fields section
|
||||
Divider()
|
||||
Text(
|
||||
text = "Optional Details",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = bedrooms,
|
||||
onValueChange = { bedrooms = it.filter { char -> char.isDigit() } },
|
||||
label = { Text("Bedrooms") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = bathrooms,
|
||||
onValueChange = { bathrooms = it },
|
||||
label = { Text("Bathrooms") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = squareFootage,
|
||||
onValueChange = { squareFootage = it.filter { char -> char.isDigit() } },
|
||||
label = { Text("Square Footage") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = lotSize,
|
||||
onValueChange = { lotSize = it },
|
||||
label = { Text("Lot Size (acres)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = yearBuilt,
|
||||
onValueChange = { yearBuilt = it.filter { char -> char.isDigit() } },
|
||||
label = { Text("Year Built") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Description") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("Primary Residence")
|
||||
Switch(
|
||||
checked = isPrimary,
|
||||
onCheckedChange = { isPrimary = it }
|
||||
)
|
||||
}
|
||||
|
||||
// Error message
|
||||
if (updateState is ApiResult.Error) {
|
||||
Text(
|
||||
text = (updateState as ApiResult.Error).message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
// Submit button
|
||||
Button(
|
||||
onClick = {
|
||||
if (validateForm() && propertyType != null) {
|
||||
viewModel.updateResidence(
|
||||
residenceId = residence.id,
|
||||
request = ResidenceCreateRequest(
|
||||
name = name,
|
||||
propertyType = propertyType!!.id,
|
||||
streetAddress = streetAddress,
|
||||
apartmentUnit = apartmentUnit.ifBlank { null },
|
||||
city = city,
|
||||
stateProvince = stateProvince,
|
||||
postalCode = postalCode,
|
||||
country = country,
|
||||
bedrooms = bedrooms.toIntOrNull(),
|
||||
bathrooms = bathrooms.toFloatOrNull(),
|
||||
squareFootage = squareFootage.toIntOrNull(),
|
||||
lotSize = lotSize.toFloatOrNull(),
|
||||
yearBuilt = yearBuilt.toIntOrNull(),
|
||||
description = description.ifBlank { null },
|
||||
isPrimary = isPrimary
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = validateForm() && propertyType != null
|
||||
) {
|
||||
if (updateState is ApiResult.Loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Update Residence")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
ResidenceFormScreen(
|
||||
existingResidence = residence,
|
||||
onNavigateBack = onNavigateBack,
|
||||
onSuccess = onResidenceUpdated,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
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.ArrowBack
|
||||
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.ResidenceViewModel
|
||||
import com.mycrib.repository.LookupsRepository
|
||||
import com.mycrib.shared.models.Residence
|
||||
import com.mycrib.shared.models.ResidenceCreateRequest
|
||||
import com.mycrib.shared.models.ResidenceType
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ResidenceFormScreen(
|
||||
existingResidence: Residence? = null,
|
||||
onNavigateBack: () -> Unit,
|
||||
onSuccess: () -> Unit,
|
||||
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
|
||||
) {
|
||||
val isEditMode = existingResidence != null
|
||||
|
||||
// Form state
|
||||
var name by remember { mutableStateOf(existingResidence?.name ?: "") }
|
||||
var propertyType by remember { mutableStateOf<ResidenceType?>(null) }
|
||||
var streetAddress by remember { mutableStateOf(existingResidence?.streetAddress ?: "") }
|
||||
var apartmentUnit by remember { mutableStateOf(existingResidence?.apartmentUnit ?: "") }
|
||||
var city by remember { mutableStateOf(existingResidence?.city ?: "") }
|
||||
var stateProvince by remember { mutableStateOf(existingResidence?.stateProvince ?: "") }
|
||||
var postalCode by remember { mutableStateOf(existingResidence?.postalCode ?: "") }
|
||||
var country by remember { mutableStateOf(existingResidence?.country ?: "USA") }
|
||||
var bedrooms by remember { mutableStateOf(existingResidence?.bedrooms?.toString() ?: "") }
|
||||
var bathrooms by remember { mutableStateOf(existingResidence?.bathrooms?.toString() ?: "") }
|
||||
var squareFootage by remember { mutableStateOf(existingResidence?.squareFootage?.toString() ?: "") }
|
||||
var lotSize by remember { mutableStateOf(existingResidence?.lotSize?.toString() ?: "") }
|
||||
var yearBuilt by remember { mutableStateOf(existingResidence?.yearBuilt?.toString() ?: "") }
|
||||
var description by remember { mutableStateOf(existingResidence?.description ?: "") }
|
||||
var isPrimary by remember { mutableStateOf(existingResidence?.isPrimary ?: false) }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val operationState by if (isEditMode) {
|
||||
viewModel.updateResidenceState.collectAsState()
|
||||
} else {
|
||||
viewModel.createResidenceState.collectAsState()
|
||||
}
|
||||
val propertyTypes by LookupsRepository.residenceTypes.collectAsState()
|
||||
|
||||
// Validation errors
|
||||
var nameError by remember { mutableStateOf("") }
|
||||
var streetAddressError by remember { mutableStateOf("") }
|
||||
var cityError by remember { mutableStateOf("") }
|
||||
var stateProvinceError by remember { mutableStateOf("") }
|
||||
var postalCodeError by remember { mutableStateOf("") }
|
||||
|
||||
// Handle operation state changes
|
||||
LaunchedEffect(operationState) {
|
||||
when (operationState) {
|
||||
is ApiResult.Success -> {
|
||||
if (isEditMode) {
|
||||
viewModel.resetUpdateState()
|
||||
} else {
|
||||
viewModel.resetCreateState()
|
||||
}
|
||||
onSuccess()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// Set default/existing property type when types are loaded
|
||||
LaunchedEffect(propertyTypes, existingResidence) {
|
||||
if (propertyTypes.isNotEmpty() && propertyType == null) {
|
||||
propertyType = if (isEditMode && existingResidence != null) {
|
||||
propertyTypes.find { it.id == existingResidence.propertyType.toInt() }
|
||||
} else {
|
||||
propertyTypes.first()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun validateForm(): Boolean {
|
||||
var isValid = true
|
||||
|
||||
if (name.isBlank()) {
|
||||
nameError = "Name is required"
|
||||
isValid = false
|
||||
} else {
|
||||
nameError = ""
|
||||
}
|
||||
|
||||
if (streetAddress.isBlank()) {
|
||||
streetAddressError = "Street address is required"
|
||||
isValid = false
|
||||
} else {
|
||||
streetAddressError = ""
|
||||
}
|
||||
|
||||
if (city.isBlank()) {
|
||||
cityError = "City is required"
|
||||
isValid = false
|
||||
} else {
|
||||
cityError = ""
|
||||
}
|
||||
|
||||
if (stateProvince.isBlank()) {
|
||||
stateProvinceError = "State/Province is required"
|
||||
isValid = false
|
||||
} else {
|
||||
stateProvinceError = ""
|
||||
}
|
||||
|
||||
if (postalCode.isBlank()) {
|
||||
postalCodeError = "Postal code is required"
|
||||
isValid = false
|
||||
} else {
|
||||
postalCodeError = ""
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(if (isEditMode) "Edit Residence" else "Add Residence") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Required fields section
|
||||
Text(
|
||||
text = "Required Information",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Property Name *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = nameError.isNotEmpty(),
|
||||
supportingText = if (nameError.isNotEmpty()) {
|
||||
{ Text(nameError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = propertyType?.name?.replaceFirstChar { it.uppercase() } ?: "",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Property Type *") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
enabled = propertyTypes.isNotEmpty()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
propertyTypes.forEach { type ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(type.name.replaceFirstChar { it.uppercase() }) },
|
||||
onClick = {
|
||||
propertyType = type
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = streetAddress,
|
||||
onValueChange = { streetAddress = it },
|
||||
label = { Text("Street Address *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = streetAddressError.isNotEmpty(),
|
||||
supportingText = if (streetAddressError.isNotEmpty()) {
|
||||
{ Text(streetAddressError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = apartmentUnit,
|
||||
onValueChange = { apartmentUnit = it },
|
||||
label = { Text("Apartment/Unit #") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = city,
|
||||
onValueChange = { city = it },
|
||||
label = { Text("City *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = cityError.isNotEmpty(),
|
||||
supportingText = if (cityError.isNotEmpty()) {
|
||||
{ Text(cityError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = stateProvince,
|
||||
onValueChange = { stateProvince = it },
|
||||
label = { Text("State/Province *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = stateProvinceError.isNotEmpty(),
|
||||
supportingText = if (stateProvinceError.isNotEmpty()) {
|
||||
{ Text(stateProvinceError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = postalCode,
|
||||
onValueChange = { postalCode = it },
|
||||
label = { Text("Postal Code *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = postalCodeError.isNotEmpty(),
|
||||
supportingText = if (postalCodeError.isNotEmpty()) {
|
||||
{ Text(postalCodeError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = country,
|
||||
onValueChange = { country = it },
|
||||
label = { Text("Country") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Optional fields section
|
||||
Divider()
|
||||
Text(
|
||||
text = "Optional Details",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = bedrooms,
|
||||
onValueChange = { bedrooms = it.filter { char -> char.isDigit() } },
|
||||
label = { Text("Bedrooms") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = bathrooms,
|
||||
onValueChange = { bathrooms = it },
|
||||
label = { Text("Bathrooms") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = squareFootage,
|
||||
onValueChange = { squareFootage = it.filter { char -> char.isDigit() } },
|
||||
label = { Text("Square Footage") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = lotSize,
|
||||
onValueChange = { lotSize = it },
|
||||
label = { Text("Lot Size (acres)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = yearBuilt,
|
||||
onValueChange = { yearBuilt = it.filter { char -> char.isDigit() } },
|
||||
label = { Text("Year Built") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Description") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("Primary Residence")
|
||||
Switch(
|
||||
checked = isPrimary,
|
||||
onCheckedChange = { isPrimary = it }
|
||||
)
|
||||
}
|
||||
|
||||
// Error message
|
||||
if (operationState is ApiResult.Error) {
|
||||
Text(
|
||||
text = (operationState as ApiResult.Error).message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
// Submit button
|
||||
Button(
|
||||
onClick = {
|
||||
if (validateForm() && propertyType != null) {
|
||||
val request = ResidenceCreateRequest(
|
||||
name = name,
|
||||
propertyType = propertyType!!.id,
|
||||
streetAddress = streetAddress,
|
||||
apartmentUnit = apartmentUnit.ifBlank { null },
|
||||
city = city,
|
||||
stateProvince = stateProvince,
|
||||
postalCode = postalCode,
|
||||
country = country,
|
||||
bedrooms = bedrooms.toIntOrNull(),
|
||||
bathrooms = bathrooms.toFloatOrNull(),
|
||||
squareFootage = squareFootage.toIntOrNull(),
|
||||
lotSize = lotSize.toFloatOrNull(),
|
||||
yearBuilt = yearBuilt.toIntOrNull(),
|
||||
description = description.ifBlank { null },
|
||||
isPrimary = isPrimary
|
||||
)
|
||||
|
||||
if (isEditMode && existingResidence != null) {
|
||||
viewModel.updateResidence(existingResidence.id, request)
|
||||
} else {
|
||||
viewModel.createResidence(request)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = validateForm() && propertyType != null
|
||||
) {
|
||||
if (operationState is ApiResult.Loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text(if (isEditMode) "Update Residence" else "Create Residence")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user