wip
This commit is contained in:
@@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.mycrib.android.ui.screens.AddResidenceScreen
|
import com.mycrib.android.ui.screens.AddResidenceScreen
|
||||||
import com.mycrib.android.ui.screens.EditResidenceScreen
|
import com.mycrib.android.ui.screens.EditResidenceScreen
|
||||||
|
import com.mycrib.android.ui.screens.EditTaskScreen
|
||||||
import com.mycrib.android.ui.screens.HomeScreen
|
import com.mycrib.android.ui.screens.HomeScreen
|
||||||
import com.mycrib.android.ui.screens.LoginScreen
|
import com.mycrib.android.ui.screens.LoginScreen
|
||||||
import com.mycrib.android.ui.screens.RegisterScreen
|
import com.mycrib.android.ui.screens.RegisterScreen
|
||||||
@@ -218,9 +219,60 @@ fun App() {
|
|||||||
owner = residence.owner
|
owner = residence.owner
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
onNavigateToEditTask = { task ->
|
||||||
|
navController.navigate(
|
||||||
|
EditTaskRoute(
|
||||||
|
taskId = task.id,
|
||||||
|
residenceId = task.residence,
|
||||||
|
title = task.title,
|
||||||
|
description = task.description,
|
||||||
|
categoryId = task.category.id,
|
||||||
|
categoryName = task.category.name,
|
||||||
|
frequencyId = task.frequency.id,
|
||||||
|
frequencyName = task.frequency.name,
|
||||||
|
priorityId = task.priority.id,
|
||||||
|
priorityName = task.priority.name,
|
||||||
|
statusId = task.status?.id,
|
||||||
|
statusName = task.status?.name,
|
||||||
|
dueDate = task.dueDate,
|
||||||
|
estimatedCost = task.estimatedCost,
|
||||||
|
createdAt = task.createdAt,
|
||||||
|
updatedAt = task.updatedAt
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable<EditTaskRoute> { backStackEntry ->
|
||||||
|
val route = backStackEntry.toRoute<EditTaskRoute>()
|
||||||
|
EditTaskScreen(
|
||||||
|
task = com.mycrib.shared.models.TaskDetail(
|
||||||
|
id = route.taskId,
|
||||||
|
residence = route.residenceId,
|
||||||
|
title = route.title,
|
||||||
|
description = route.description,
|
||||||
|
category = com.mycrib.shared.models.TaskCategory(route.categoryId, route.categoryName),
|
||||||
|
frequency = com.mycrib.shared.models.TaskFrequency(route.frequencyId, route.frequencyName, ""),
|
||||||
|
priority = com.mycrib.shared.models.TaskPriority(route.priorityId, route.priorityName, displayName = route.statusName ?: ""),
|
||||||
|
status = route.statusId?.let {
|
||||||
|
com.mycrib.shared.models.TaskStatus(it, route.statusName ?: "", displayName = route.statusName ?: "")
|
||||||
|
},
|
||||||
|
dueDate = route.dueDate,
|
||||||
|
estimatedCost = route.estimatedCost,
|
||||||
|
actualCost = null,
|
||||||
|
notes = null,
|
||||||
|
createdAt = route.createdAt,
|
||||||
|
updatedAt = route.updatedAt,
|
||||||
|
nextScheduledDate = null,
|
||||||
|
showCompletedButton = false,
|
||||||
|
completions = emptyList()
|
||||||
|
),
|
||||||
|
onNavigateBack = { navController.popBackStack() },
|
||||||
|
onTaskUpdated = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,5 +75,12 @@ data class TaskDetail(
|
|||||||
data class TasksByResidenceResponse(
|
data class TasksByResidenceResponse(
|
||||||
@SerialName("residence_id") val residenceId: String,
|
@SerialName("residence_id") val residenceId: String,
|
||||||
val summary: TaskSummary,
|
val summary: TaskSummary,
|
||||||
val tasks: List<TaskDetail>
|
val tasks: List<TaskDetail>,
|
||||||
|
@SerialName("cancelled_tasks") val cancelledTasks: List<TaskDetail> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TaskCancelResponse(
|
||||||
|
val message: String,
|
||||||
|
val task: TaskDetail
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,5 +45,25 @@ data class EditResidenceRoute(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class ResidenceDetailRoute(val residenceId: Int)
|
data class ResidenceDetailRoute(val residenceId: Int)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class EditTaskRoute(
|
||||||
|
val taskId: Int,
|
||||||
|
val residenceId: Int,
|
||||||
|
val title: String,
|
||||||
|
val description: String?,
|
||||||
|
val categoryId: Int,
|
||||||
|
val categoryName: String,
|
||||||
|
val frequencyId: Int,
|
||||||
|
val frequencyName: String,
|
||||||
|
val priorityId: Int,
|
||||||
|
val priorityName: String,
|
||||||
|
val statusId: Int?,
|
||||||
|
val statusName: String?,
|
||||||
|
val dueDate: String,
|
||||||
|
val estimatedCost: String?,
|
||||||
|
val createdAt: String,
|
||||||
|
val updatedAt: String
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
object TasksRoute
|
object TasksRoute
|
||||||
|
|||||||
@@ -109,4 +109,36 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun cancelTask(token: String, id: Int): ApiResult<TaskCancelResponse> {
|
||||||
|
return try {
|
||||||
|
val response = client.post("$baseUrl/tasks/$id/cancel/") {
|
||||||
|
header("Authorization", "Token $token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(response.body())
|
||||||
|
} else {
|
||||||
|
ApiResult.Error("Failed to cancel task", response.status.value)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun uncancelTask(token: String, id: Int): ApiResult<TaskCancelResponse> {
|
||||||
|
return try {
|
||||||
|
val response = client.post("$baseUrl/tasks/$id/uncancel/") {
|
||||||
|
header("Authorization", "Token $token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(response.body())
|
||||||
|
} else {
|
||||||
|
ApiResult.Error("Failed to uncancel task", response.status.value)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
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.*
|
||||||
|
import com.mycrib.shared.network.ApiResult
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun EditTaskScreen(
|
||||||
|
task: TaskDetail,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onTaskUpdated: () -> Unit,
|
||||||
|
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
|
||||||
|
) {
|
||||||
|
var title by remember { mutableStateOf(task.title) }
|
||||||
|
var description by remember { mutableStateOf(task.description ?: "") }
|
||||||
|
var selectedCategory by remember { mutableStateOf<TaskCategory?>(task.category) }
|
||||||
|
var selectedFrequency by remember { mutableStateOf<TaskFrequency?>(task.frequency) }
|
||||||
|
var selectedPriority by remember { mutableStateOf<TaskPriority?>(task.priority) }
|
||||||
|
var selectedStatus by remember { mutableStateOf<TaskStatus?>(task.status) }
|
||||||
|
var dueDate by remember { mutableStateOf(task.dueDate) }
|
||||||
|
var estimatedCost by remember { mutableStateOf(task.estimatedCost ?: "") }
|
||||||
|
|
||||||
|
var categoryExpanded by remember { mutableStateOf(false) }
|
||||||
|
var frequencyExpanded by remember { mutableStateOf(false) }
|
||||||
|
var priorityExpanded by remember { mutableStateOf(false) }
|
||||||
|
var statusExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val updateTaskState by viewModel.updateTaskState.collectAsState()
|
||||||
|
val categories by LookupsRepository.taskCategories.collectAsState()
|
||||||
|
val frequencies by LookupsRepository.taskFrequencies.collectAsState()
|
||||||
|
val priorities by LookupsRepository.taskPriorities.collectAsState()
|
||||||
|
val statuses by LookupsRepository.taskStatuses.collectAsState()
|
||||||
|
|
||||||
|
// Validation errors
|
||||||
|
var titleError by remember { mutableStateOf("") }
|
||||||
|
var dueDateError by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
// Handle update state changes
|
||||||
|
LaunchedEffect(updateTaskState) {
|
||||||
|
when (updateTaskState) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
viewModel.resetUpdateTaskState()
|
||||||
|
onTaskUpdated()
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateForm(): Boolean {
|
||||||
|
var isValid = true
|
||||||
|
|
||||||
|
if (title.isBlank()) {
|
||||||
|
titleError = "Title is required"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
titleError = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dueDate.isBlank()) {
|
||||||
|
dueDateError = "Due date is required"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
dueDateError = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Edit Task") },
|
||||||
|
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 = "Task Details",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = title,
|
||||||
|
onValueChange = { title = it },
|
||||||
|
label = { Text("Title *") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
isError = titleError.isNotEmpty(),
|
||||||
|
supportingText = if (titleError.isNotEmpty()) {
|
||||||
|
{ Text(titleError) }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = description,
|
||||||
|
onValueChange = { description = it },
|
||||||
|
label = { Text("Description") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 3,
|
||||||
|
maxLines = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// Category dropdown
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = categoryExpanded,
|
||||||
|
onExpandedChange = { categoryExpanded = it }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedCategory?.name?.replaceFirstChar { it.uppercase() } ?: "",
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("Category *") },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoryExpanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(),
|
||||||
|
enabled = categories.isNotEmpty()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = categoryExpanded,
|
||||||
|
onDismissRequest = { categoryExpanded = false }
|
||||||
|
) {
|
||||||
|
categories.forEach { category ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(category.name.replaceFirstChar { it.uppercase() }) },
|
||||||
|
onClick = {
|
||||||
|
selectedCategory = category
|
||||||
|
categoryExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequency dropdown
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = frequencyExpanded,
|
||||||
|
onExpandedChange = { frequencyExpanded = it }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedFrequency?.name?.replaceFirstChar { it.uppercase() } ?: "",
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("Frequency *") },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = frequencyExpanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(),
|
||||||
|
enabled = frequencies.isNotEmpty()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = frequencyExpanded,
|
||||||
|
onDismissRequest = { frequencyExpanded = false }
|
||||||
|
) {
|
||||||
|
frequencies.forEach { frequency ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(frequency.name.replaceFirstChar { it.uppercase() }) },
|
||||||
|
onClick = {
|
||||||
|
selectedFrequency = frequency
|
||||||
|
frequencyExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority dropdown
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = priorityExpanded,
|
||||||
|
onExpandedChange = { priorityExpanded = it }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedPriority?.name?.replaceFirstChar { it.uppercase() } ?: "",
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("Priority *") },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = priorityExpanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(),
|
||||||
|
enabled = priorities.isNotEmpty()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = priorityExpanded,
|
||||||
|
onDismissRequest = { priorityExpanded = false }
|
||||||
|
) {
|
||||||
|
priorities.forEach { priority ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(priority.name.replaceFirstChar { it.uppercase() }) },
|
||||||
|
onClick = {
|
||||||
|
selectedPriority = priority
|
||||||
|
priorityExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status dropdown
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = statusExpanded,
|
||||||
|
onExpandedChange = { statusExpanded = it }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedStatus?.name?.replaceFirstChar { it.uppercase() } ?: "",
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("Status *") },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = statusExpanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(),
|
||||||
|
enabled = statuses.isNotEmpty()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = statusExpanded,
|
||||||
|
onDismissRequest = { statusExpanded = false }
|
||||||
|
) {
|
||||||
|
statuses.forEach { status ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(status.name.replaceFirstChar { it.uppercase() }) },
|
||||||
|
onClick = {
|
||||||
|
selectedStatus = status
|
||||||
|
statusExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = dueDate,
|
||||||
|
onValueChange = { dueDate = it },
|
||||||
|
label = { Text("Due Date (YYYY-MM-DD) *") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
isError = dueDateError.isNotEmpty(),
|
||||||
|
supportingText = if (dueDateError.isNotEmpty()) {
|
||||||
|
{ Text(dueDateError) }
|
||||||
|
} else null,
|
||||||
|
placeholder = { Text("2025-01-31") }
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = estimatedCost,
|
||||||
|
onValueChange = { estimatedCost = it },
|
||||||
|
label = { Text("Estimated Cost") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
prefix = { Text("$") }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
if (updateTaskState is ApiResult.Error) {
|
||||||
|
Text(
|
||||||
|
text = (updateTaskState as ApiResult.Error).message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit button
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (validateForm() && selectedCategory != null &&
|
||||||
|
selectedFrequency != null && selectedPriority != null &&
|
||||||
|
selectedStatus != null) {
|
||||||
|
viewModel.updateTask(
|
||||||
|
taskId = task.id,
|
||||||
|
request = TaskCreateRequest(
|
||||||
|
residence = task.residence,
|
||||||
|
title = title,
|
||||||
|
description = description.ifBlank { null },
|
||||||
|
category = selectedCategory!!.id,
|
||||||
|
frequency = selectedFrequency!!.id,
|
||||||
|
priority = selectedPriority!!.id,
|
||||||
|
status = selectedStatus!!.id,
|
||||||
|
dueDate = dueDate,
|
||||||
|
estimatedCost = estimatedCost.ifBlank { null }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = validateForm() && selectedCategory != null &&
|
||||||
|
selectedFrequency != null && selectedPriority != null &&
|
||||||
|
selectedStatus != null
|
||||||
|
) {
|
||||||
|
if (updateTaskState is ApiResult.Loading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Update Task")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ fun ResidenceDetailScreen(
|
|||||||
residenceId: Int,
|
residenceId: Int,
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
onNavigateToEditResidence: (Residence) -> Unit,
|
onNavigateToEditResidence: (Residence) -> Unit,
|
||||||
|
onNavigateToEditTask: (TaskDetail) -> Unit,
|
||||||
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
|
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
|
||||||
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
|
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
|
||||||
taskViewModel: TaskViewModel = viewModel { TaskViewModel() }
|
taskViewModel: TaskViewModel = viewModel { TaskViewModel() }
|
||||||
@@ -36,10 +37,13 @@ fun ResidenceDetailScreen(
|
|||||||
val tasksState by residenceViewModel.residenceTasksState.collectAsState()
|
val tasksState by residenceViewModel.residenceTasksState.collectAsState()
|
||||||
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
|
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
|
||||||
val taskAddNewTaskState by taskViewModel.taskAddNewCustomTaskState.collectAsState()
|
val taskAddNewTaskState by taskViewModel.taskAddNewCustomTaskState.collectAsState()
|
||||||
|
val cancelTaskState by residenceViewModel.cancelTaskState.collectAsState()
|
||||||
|
val uncancelTaskState by residenceViewModel.uncancelTaskState.collectAsState()
|
||||||
|
|
||||||
var showCompleteDialog by remember { mutableStateOf(false) }
|
var showCompleteDialog by remember { mutableStateOf(false) }
|
||||||
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
|
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
|
||||||
var showNewTaskDialog by remember { mutableStateOf(false) }
|
var showNewTaskDialog by remember { mutableStateOf(false) }
|
||||||
|
var showCancelledTasks by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(residenceId) {
|
LaunchedEffect(residenceId) {
|
||||||
residenceViewModel.getResidence(residenceId) { result ->
|
residenceViewModel.getResidence(residenceId) { result ->
|
||||||
@@ -72,6 +76,28 @@ fun ResidenceDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle cancel task success
|
||||||
|
LaunchedEffect(cancelTaskState) {
|
||||||
|
when (cancelTaskState) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
residenceViewModel.resetCancelTaskState()
|
||||||
|
residenceViewModel.loadResidenceTasks(residenceId)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle uncancel task success
|
||||||
|
LaunchedEffect(uncancelTaskState) {
|
||||||
|
when (uncancelTaskState) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
residenceViewModel.resetUncancelTaskState()
|
||||||
|
residenceViewModel.loadResidenceTasks(residenceId)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showCompleteDialog && selectedTask != null) {
|
if (showCompleteDialog && selectedTask != null) {
|
||||||
CompleteTaskDialog(
|
CompleteTaskDialog(
|
||||||
taskId = selectedTask!!.id,
|
taskId = selectedTask!!.id,
|
||||||
@@ -362,7 +388,7 @@ fun ResidenceDetailScreen(
|
|||||||
}
|
}
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
val taskData = (tasksState as ApiResult.Success).data
|
val taskData = (tasksState as ApiResult.Success).data
|
||||||
if (taskData.tasks.isEmpty()) {
|
if (taskData.tasks.isEmpty() && taskData.cancelledTasks.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -401,6 +427,60 @@ fun ResidenceDetailScreen(
|
|||||||
onCompleteClick = {
|
onCompleteClick = {
|
||||||
selectedTask = task
|
selectedTask = task
|
||||||
showCompleteDialog = true
|
showCompleteDialog = true
|
||||||
|
},
|
||||||
|
onEditClick = {
|
||||||
|
onNavigateToEditTask(task)
|
||||||
|
},
|
||||||
|
onCancelClick = {
|
||||||
|
residenceViewModel.cancelTask(task.id)
|
||||||
|
},
|
||||||
|
onUncancelClick = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancelled tasks section
|
||||||
|
if (taskData.cancelledTasks.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Cancel,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Cancelled Tasks (${taskData.cancelledTasks.size})",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = { showCancelledTasks = !showCancelledTasks }) {
|
||||||
|
Text(if (showCancelledTasks) "Hide" else "Show")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCancelledTasks) {
|
||||||
|
items(taskData.cancelledTasks) { task ->
|
||||||
|
TaskCard(
|
||||||
|
task = task,
|
||||||
|
onCompleteClick = null,
|
||||||
|
onEditClick = {
|
||||||
|
onNavigateToEditTask(task)
|
||||||
|
},
|
||||||
|
onCancelClick = null,
|
||||||
|
onUncancelClick = {
|
||||||
|
residenceViewModel.uncancelTask(task.id)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -411,6 +491,8 @@ fun ResidenceDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -515,7 +597,10 @@ private fun DetailRow(
|
|||||||
@Composable
|
@Composable
|
||||||
fun TaskCard(
|
fun TaskCard(
|
||||||
task: TaskDetail,
|
task: TaskDetail,
|
||||||
onCompleteClick: () -> Unit
|
onCompleteClick: (() -> Unit)?,
|
||||||
|
onEditClick: () -> Unit,
|
||||||
|
onCancelClick: (() -> Unit)?,
|
||||||
|
onUncancelClick: (() -> Unit)?
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -733,7 +818,7 @@ fun TaskCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show complete task button based on API logic
|
// Show complete task button based on API logic
|
||||||
if (task.showCompletedButton) {
|
if (task.showCompletedButton && onCompleteClick != null) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = onCompleteClick,
|
onClick = onCompleteClick,
|
||||||
@@ -753,6 +838,68 @@ fun TaskCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Action buttons row
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
// Edit button
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onEditClick,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Edit,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Edit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel or Uncancel button
|
||||||
|
when {
|
||||||
|
onCancelClick != null -> {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onCancelClick,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Cancel,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onUncancelClick != null -> {
|
||||||
|
Button(
|
||||||
|
onClick = onUncancelClick,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Undo,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Restore")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ class ResidenceViewModel : ViewModel() {
|
|||||||
private val _myResidencesState = MutableStateFlow<ApiResult<MyResidencesResponse>>(ApiResult.Loading)
|
private val _myResidencesState = MutableStateFlow<ApiResult<MyResidencesResponse>>(ApiResult.Loading)
|
||||||
val myResidencesState: StateFlow<ApiResult<MyResidencesResponse>> = _myResidencesState
|
val myResidencesState: StateFlow<ApiResult<MyResidencesResponse>> = _myResidencesState
|
||||||
|
|
||||||
|
private val _cancelTaskState = MutableStateFlow<ApiResult<com.mycrib.shared.models.TaskCancelResponse>>(ApiResult.Loading)
|
||||||
|
val cancelTaskState: StateFlow<ApiResult<com.mycrib.shared.models.TaskCancelResponse>> = _cancelTaskState
|
||||||
|
|
||||||
|
private val _uncancelTaskState = MutableStateFlow<ApiResult<com.mycrib.shared.models.TaskCancelResponse>>(ApiResult.Loading)
|
||||||
|
val uncancelTaskState: StateFlow<ApiResult<com.mycrib.shared.models.TaskCancelResponse>> = _uncancelTaskState
|
||||||
|
|
||||||
|
private val _updateTaskState = MutableStateFlow<ApiResult<com.mycrib.shared.models.CustomTask>>(ApiResult.Loading)
|
||||||
|
val updateTaskState: StateFlow<ApiResult<com.mycrib.shared.models.CustomTask>> = _updateTaskState
|
||||||
|
|
||||||
fun loadResidences() {
|
fun loadResidences() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_residencesState.value = ApiResult.Loading
|
_residencesState.value = ApiResult.Loading
|
||||||
@@ -132,4 +141,52 @@ class ResidenceViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun cancelTask(taskId: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_cancelTaskState.value = ApiResult.Loading
|
||||||
|
val token = TokenStorage.getToken()
|
||||||
|
if (token != null) {
|
||||||
|
_cancelTaskState.value = taskApi.cancelTask(token, taskId)
|
||||||
|
} else {
|
||||||
|
_cancelTaskState.value = ApiResult.Error("Not authenticated", 401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uncancelTask(taskId: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uncancelTaskState.value = ApiResult.Loading
|
||||||
|
val token = TokenStorage.getToken()
|
||||||
|
if (token != null) {
|
||||||
|
_uncancelTaskState.value = taskApi.uncancelTask(token, taskId)
|
||||||
|
} else {
|
||||||
|
_uncancelTaskState.value = ApiResult.Error("Not authenticated", 401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateTask(taskId: Int, request: com.mycrib.shared.models.TaskCreateRequest) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_updateTaskState.value = ApiResult.Loading
|
||||||
|
val token = TokenStorage.getToken()
|
||||||
|
if (token != null) {
|
||||||
|
_updateTaskState.value = taskApi.updateTask(token, taskId, request)
|
||||||
|
} else {
|
||||||
|
_updateTaskState.value = ApiResult.Error("Not authenticated", 401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetCancelTaskState() {
|
||||||
|
_cancelTaskState.value = ApiResult.Loading
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetUncancelTaskState() {
|
||||||
|
_uncancelTaskState.value = ApiResult.Loading
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetUpdateTaskState() {
|
||||||
|
_updateTaskState.value = ApiResult.Loading
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,6 @@ struct AddResidenceView: View {
|
|||||||
@State private var stateProvinceError: String = ""
|
@State private var stateProvinceError: String = ""
|
||||||
@State private var postalCodeError: String = ""
|
@State private var postalCodeError: String = ""
|
||||||
|
|
||||||
// Picker state
|
|
||||||
@State private var showPropertyTypePicker = false
|
|
||||||
|
|
||||||
enum Field {
|
enum Field {
|
||||||
case name, streetAddress, apartmentUnit, city, stateProvince, postalCode, country
|
case name, streetAddress, apartmentUnit, city, stateProvince, postalCode, country
|
||||||
case bedrooms, bathrooms, squareFootage, lotSize, yearBuilt, description
|
case bedrooms, bathrooms, squareFootage, lotSize, yearBuilt, description
|
||||||
@@ -41,203 +38,117 @@ struct AddResidenceView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
ZStack {
|
Form {
|
||||||
Color(.systemGroupedBackground)
|
Section(header: Text("Property Details")) {
|
||||||
.ignoresSafeArea()
|
TextField("Property Name", text: $name)
|
||||||
|
.focused($focusedField, equals: .name)
|
||||||
|
|
||||||
ScrollView {
|
if !nameError.isEmpty {
|
||||||
VStack(spacing: 24) {
|
Text(nameError)
|
||||||
// Required Information Section
|
.font(.caption)
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
.foregroundColor(.red)
|
||||||
Text("Required Information")
|
}
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
|
|
||||||
FormTextField(
|
Picker("Property Type", selection: $selectedPropertyType) {
|
||||||
label: "Property Name",
|
Text("Select Type").tag(nil as ResidenceType?)
|
||||||
text: $name,
|
ForEach(lookupsManager.residenceTypes, id: \.id) { type in
|
||||||
error: nameError,
|
Text(type.name).tag(type as ResidenceType?)
|
||||||
placeholder: "My Home",
|
}
|
||||||
focusedField: $focusedField,
|
}
|
||||||
field: .name
|
}
|
||||||
)
|
|
||||||
|
|
||||||
// Property Type Picker
|
Section(header: Text("Address")) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
TextField("Street Address", text: $streetAddress)
|
||||||
Text("Property Type")
|
.focused($focusedField, equals: .streetAddress)
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
Button(action: {
|
if !streetAddressError.isEmpty {
|
||||||
showPropertyTypePicker = true
|
Text(streetAddressError)
|
||||||
}) {
|
.font(.caption)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("Apartment/Unit (optional)", text: $apartmentUnit)
|
||||||
|
.focused($focusedField, equals: .apartmentUnit)
|
||||||
|
|
||||||
|
TextField("City", text: $city)
|
||||||
|
.focused($focusedField, equals: .city)
|
||||||
|
|
||||||
|
if !cityError.isEmpty {
|
||||||
|
Text(cityError)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("State/Province", text: $stateProvince)
|
||||||
|
.focused($focusedField, equals: .stateProvince)
|
||||||
|
|
||||||
|
if !stateProvinceError.isEmpty {
|
||||||
|
Text(stateProvinceError)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("Postal Code", text: $postalCode)
|
||||||
|
.focused($focusedField, equals: .postalCode)
|
||||||
|
|
||||||
|
if !postalCodeError.isEmpty {
|
||||||
|
Text(postalCodeError)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("Country", text: $country)
|
||||||
|
.focused($focusedField, equals: .country)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Property Features")) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(selectedPropertyType?.name ?? "Select Type")
|
Text("Bedrooms")
|
||||||
.foregroundColor(selectedPropertyType == nil ? .gray : .primary)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "chevron.down")
|
TextField("0", text: $bedrooms)
|
||||||
.foregroundColor(.gray)
|
.keyboardType(.numberPad)
|
||||||
}
|
.multilineTextAlignment(.trailing)
|
||||||
.padding()
|
.frame(width: 60)
|
||||||
.background(Color(.systemBackground))
|
.focused($focusedField, equals: .bedrooms)
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FormTextField(
|
HStack {
|
||||||
label: "Street Address",
|
Text("Bathrooms")
|
||||||
text: $streetAddress,
|
Spacer()
|
||||||
error: streetAddressError,
|
TextField("0.0", text: $bathrooms)
|
||||||
placeholder: "123 Main St",
|
.keyboardType(.decimalPad)
|
||||||
focusedField: $focusedField,
|
.multilineTextAlignment(.trailing)
|
||||||
field: .streetAddress
|
.frame(width: 60)
|
||||||
)
|
.focused($focusedField, equals: .bathrooms)
|
||||||
|
|
||||||
FormTextField(
|
|
||||||
label: "Apartment/Unit (Optional)",
|
|
||||||
text: $apartmentUnit,
|
|
||||||
error: "",
|
|
||||||
placeholder: "Apt 4B",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .apartmentUnit
|
|
||||||
)
|
|
||||||
|
|
||||||
FormTextField(
|
|
||||||
label: "City",
|
|
||||||
text: $city,
|
|
||||||
error: cityError,
|
|
||||||
placeholder: "San Francisco",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .city
|
|
||||||
)
|
|
||||||
|
|
||||||
FormTextField(
|
|
||||||
label: "State/Province",
|
|
||||||
text: $stateProvince,
|
|
||||||
error: stateProvinceError,
|
|
||||||
placeholder: "CA",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .stateProvince
|
|
||||||
)
|
|
||||||
|
|
||||||
FormTextField(
|
|
||||||
label: "Postal Code",
|
|
||||||
text: $postalCode,
|
|
||||||
error: postalCodeError,
|
|
||||||
placeholder: "94102",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .postalCode
|
|
||||||
)
|
|
||||||
|
|
||||||
FormTextField(
|
|
||||||
label: "Country",
|
|
||||||
text: $country,
|
|
||||||
error: "",
|
|
||||||
placeholder: "USA",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .country
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional Information Section
|
TextField("Square Footage", text: $squareFootage)
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
.keyboardType(.numberPad)
|
||||||
Text("Optional Information")
|
.focused($focusedField, equals: .squareFootage)
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
TextField("Lot Size (acres)", text: $lotSize)
|
||||||
FormTextField(
|
.keyboardType(.decimalPad)
|
||||||
label: "Bedrooms",
|
.focused($focusedField, equals: .lotSize)
|
||||||
text: $bedrooms,
|
|
||||||
error: "",
|
|
||||||
placeholder: "3",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .bedrooms,
|
|
||||||
keyboardType: .numberPad
|
|
||||||
)
|
|
||||||
|
|
||||||
FormTextField(
|
TextField("Year Built", text: $yearBuilt)
|
||||||
label: "Bathrooms",
|
.keyboardType(.numberPad)
|
||||||
text: $bathrooms,
|
.focused($focusedField, equals: .yearBuilt)
|
||||||
error: "",
|
|
||||||
placeholder: "2.5",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .bathrooms,
|
|
||||||
keyboardType: .decimalPad
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FormTextField(
|
Section(header: Text("Additional Details")) {
|
||||||
label: "Square Footage",
|
TextField("Description (optional)", text: $description, axis: .vertical)
|
||||||
text: $squareFootage,
|
.lineLimit(3...6)
|
||||||
error: "",
|
|
||||||
placeholder: "1800",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .squareFootage,
|
|
||||||
keyboardType: .numberPad
|
|
||||||
)
|
|
||||||
|
|
||||||
FormTextField(
|
|
||||||
label: "Lot Size (acres)",
|
|
||||||
text: $lotSize,
|
|
||||||
error: "",
|
|
||||||
placeholder: "0.25",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .lotSize,
|
|
||||||
keyboardType: .decimalPad
|
|
||||||
)
|
|
||||||
|
|
||||||
FormTextField(
|
|
||||||
label: "Year Built",
|
|
||||||
text: $yearBuilt,
|
|
||||||
error: "",
|
|
||||||
placeholder: "2010",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .yearBuilt,
|
|
||||||
keyboardType: .numberPad
|
|
||||||
)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Description")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
TextEditor(text: $description)
|
|
||||||
.frame(height: 100)
|
|
||||||
.padding(8)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.cornerRadius(8)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Toggle("Primary Residence", isOn: $isPrimary)
|
Toggle("Primary Residence", isOn: $isPrimary)
|
||||||
.font(.subheadline)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit Button
|
if let errorMessage = viewModel.errorMessage {
|
||||||
Button(action: submitForm) {
|
Section {
|
||||||
HStack {
|
Text(errorMessage)
|
||||||
if viewModel.isLoading {
|
.foregroundColor(.red)
|
||||||
ProgressView()
|
.font(.caption)
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
||||||
} else {
|
|
||||||
Text("Add Property")
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(viewModel.isLoading ? Color.gray : Color.blue)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.cornerRadius(12)
|
|
||||||
}
|
|
||||||
.disabled(viewModel.isLoading)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.navigationTitle("Add Residence")
|
.navigationTitle("Add Residence")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -247,13 +158,13 @@ struct AddResidenceView: View {
|
|||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Save") {
|
||||||
|
submitForm()
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isLoading)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showPropertyTypePicker) {
|
|
||||||
PropertyTypePickerView(
|
|
||||||
propertyTypes: lookupsManager.residenceTypes,
|
|
||||||
selectedType: $selectedPropertyType,
|
|
||||||
isPresented: $showPropertyTypePicker
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
setDefaults()
|
setDefaults()
|
||||||
@@ -344,69 +255,6 @@ struct AddResidenceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FormTextField: View {
|
|
||||||
let label: String
|
|
||||||
@Binding var text: String
|
|
||||||
let error: String
|
|
||||||
let placeholder: String
|
|
||||||
var focusedField: FocusState<AddResidenceView.Field?>.Binding
|
|
||||||
let field: AddResidenceView.Field
|
|
||||||
var keyboardType: UIKeyboardType = .default
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(label)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
TextField(placeholder, text: $text)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.keyboardType(keyboardType)
|
|
||||||
.focused(focusedField, equals: field)
|
|
||||||
|
|
||||||
if !error.isEmpty {
|
|
||||||
Text(error)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.red)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PropertyTypePickerView: View {
|
|
||||||
let propertyTypes: [ResidenceType]
|
|
||||||
@Binding var selectedType: ResidenceType?
|
|
||||||
@Binding var isPresented: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationView {
|
|
||||||
List(propertyTypes, id: \.id) { type in
|
|
||||||
Button(action: {
|
|
||||||
selectedType = type
|
|
||||||
isPresented = false
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Text(type.name)
|
|
||||||
Spacer()
|
|
||||||
if selectedType?.id == type.id {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Select Property Type")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button("Done") {
|
|
||||||
isPresented = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
AddResidenceView(isPresented: .constant(true))
|
AddResidenceView(isPresented: .constant(true))
|
||||||
|
|||||||
@@ -32,210 +32,121 @@ struct EditResidenceView: View {
|
|||||||
@State private var stateProvinceError: String = ""
|
@State private var stateProvinceError: String = ""
|
||||||
@State private var postalCodeError: String = ""
|
@State private var postalCodeError: String = ""
|
||||||
|
|
||||||
// Picker state
|
|
||||||
@State private var showPropertyTypePicker = false
|
|
||||||
|
|
||||||
typealias Field = AddResidenceView.Field
|
typealias Field = AddResidenceView.Field
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
ZStack {
|
Form {
|
||||||
Color(.systemGroupedBackground)
|
Section(header: Text("Property Details")) {
|
||||||
.ignoresSafeArea()
|
TextField("Property Name", text: $name)
|
||||||
|
.focused($focusedField, equals: .name)
|
||||||
|
|
||||||
ScrollView {
|
if !nameError.isEmpty {
|
||||||
VStack(spacing: 24) {
|
Text(nameError)
|
||||||
// Required Information Section
|
.font(.caption)
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
.foregroundColor(.red)
|
||||||
Text("Required Information")
|
}
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
|
|
||||||
FormTextField(
|
Picker("Property Type", selection: $selectedPropertyType) {
|
||||||
label: "Property Name",
|
Text("Select Type").tag(nil as ResidenceType?)
|
||||||
text: $name,
|
ForEach(lookupsManager.residenceTypes, id: \.id) { type in
|
||||||
error: nameError,
|
Text(type.name).tag(type as ResidenceType?)
|
||||||
placeholder: "My Home",
|
}
|
||||||
focusedField: $focusedField,
|
}
|
||||||
field: .name
|
}
|
||||||
)
|
|
||||||
|
|
||||||
// Property Type Picker
|
Section(header: Text("Address")) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
TextField("Street Address", text: $streetAddress)
|
||||||
Text("Property Type")
|
.focused($focusedField, equals: .streetAddress)
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
Button(action: {
|
if !streetAddressError.isEmpty {
|
||||||
showPropertyTypePicker = true
|
Text(streetAddressError)
|
||||||
}) {
|
.font(.caption)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("Apartment/Unit (optional)", text: $apartmentUnit)
|
||||||
|
.focused($focusedField, equals: .apartmentUnit)
|
||||||
|
|
||||||
|
TextField("City", text: $city)
|
||||||
|
.focused($focusedField, equals: .city)
|
||||||
|
|
||||||
|
if !cityError.isEmpty {
|
||||||
|
Text(cityError)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("State/Province", text: $stateProvince)
|
||||||
|
.focused($focusedField, equals: .stateProvince)
|
||||||
|
|
||||||
|
if !stateProvinceError.isEmpty {
|
||||||
|
Text(stateProvinceError)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("Postal Code", text: $postalCode)
|
||||||
|
.focused($focusedField, equals: .postalCode)
|
||||||
|
|
||||||
|
if !postalCodeError.isEmpty {
|
||||||
|
Text(postalCodeError)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("Country", text: $country)
|
||||||
|
.focused($focusedField, equals: .country)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Property Features")) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(selectedPropertyType?.name ?? "Select Type")
|
Text("Bedrooms")
|
||||||
.foregroundColor(selectedPropertyType == nil ? .gray : .primary)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "chevron.down")
|
TextField("0", text: $bedrooms)
|
||||||
.foregroundColor(.gray)
|
.keyboardType(.numberPad)
|
||||||
}
|
.multilineTextAlignment(.trailing)
|
||||||
.padding()
|
.frame(width: 60)
|
||||||
.background(Color(.systemBackground))
|
.focused($focusedField, equals: .bedrooms)
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FormTextField(
|
HStack {
|
||||||
label: "Street Address",
|
Text("Bathrooms")
|
||||||
text: $streetAddress,
|
Spacer()
|
||||||
error: streetAddressError,
|
TextField("0.0", text: $bathrooms)
|
||||||
placeholder: "123 Main St",
|
.keyboardType(.decimalPad)
|
||||||
focusedField: $focusedField,
|
.multilineTextAlignment(.trailing)
|
||||||
field: .streetAddress
|
.frame(width: 60)
|
||||||
)
|
.focused($focusedField, equals: .bathrooms)
|
||||||
|
|
||||||
FormTextField(
|
|
||||||
label: "Apartment/Unit (Optional)",
|
|
||||||
text: $apartmentUnit,
|
|
||||||
error: "",
|
|
||||||
placeholder: "Apt 4B",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .apartmentUnit
|
|
||||||
)
|
|
||||||
|
|
||||||
FormTextField(
|
|
||||||
label: "City",
|
|
||||||
text: $city,
|
|
||||||
error: cityError,
|
|
||||||
placeholder: "San Francisco",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .city
|
|
||||||
)
|
|
||||||
|
|
||||||
FormTextField(
|
|
||||||
label: "State/Province",
|
|
||||||
text: $stateProvince,
|
|
||||||
error: stateProvinceError,
|
|
||||||
placeholder: "CA",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .stateProvince
|
|
||||||
)
|
|
||||||
|
|
||||||
FormTextField(
|
|
||||||
label: "Postal Code",
|
|
||||||
text: $postalCode,
|
|
||||||
error: postalCodeError,
|
|
||||||
placeholder: "94102",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .postalCode
|
|
||||||
)
|
|
||||||
|
|
||||||
FormTextField(
|
|
||||||
label: "Country",
|
|
||||||
text: $country,
|
|
||||||
error: "",
|
|
||||||
placeholder: "USA",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .country
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional Information Section
|
TextField("Square Footage", text: $squareFootage)
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
.keyboardType(.numberPad)
|
||||||
Text("Optional Information")
|
.focused($focusedField, equals: .squareFootage)
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
TextField("Lot Size (acres)", text: $lotSize)
|
||||||
FormTextField(
|
.keyboardType(.decimalPad)
|
||||||
label: "Bedrooms",
|
.focused($focusedField, equals: .lotSize)
|
||||||
text: $bedrooms,
|
|
||||||
error: "",
|
|
||||||
placeholder: "3",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .bedrooms,
|
|
||||||
keyboardType: .numberPad
|
|
||||||
)
|
|
||||||
|
|
||||||
FormTextField(
|
TextField("Year Built", text: $yearBuilt)
|
||||||
label: "Bathrooms",
|
.keyboardType(.numberPad)
|
||||||
text: $bathrooms,
|
.focused($focusedField, equals: .yearBuilt)
|
||||||
error: "",
|
|
||||||
placeholder: "2.5",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .bathrooms,
|
|
||||||
keyboardType: .decimalPad
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FormTextField(
|
Section(header: Text("Additional Details")) {
|
||||||
label: "Square Footage",
|
TextField("Description (optional)", text: $description, axis: .vertical)
|
||||||
text: $squareFootage,
|
.lineLimit(3...6)
|
||||||
error: "",
|
|
||||||
placeholder: "1800",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .squareFootage,
|
|
||||||
keyboardType: .numberPad
|
|
||||||
)
|
|
||||||
|
|
||||||
FormTextField(
|
|
||||||
label: "Lot Size (acres)",
|
|
||||||
text: $lotSize,
|
|
||||||
error: "",
|
|
||||||
placeholder: "0.25",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .lotSize,
|
|
||||||
keyboardType: .decimalPad
|
|
||||||
)
|
|
||||||
|
|
||||||
FormTextField(
|
|
||||||
label: "Year Built",
|
|
||||||
text: $yearBuilt,
|
|
||||||
error: "",
|
|
||||||
placeholder: "2010",
|
|
||||||
focusedField: $focusedField,
|
|
||||||
field: .yearBuilt,
|
|
||||||
keyboardType: .numberPad
|
|
||||||
)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Description")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
TextEditor(text: $description)
|
|
||||||
.frame(height: 100)
|
|
||||||
.padding(8)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.cornerRadius(8)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Toggle("Primary Residence", isOn: $isPrimary)
|
Toggle("Primary Residence", isOn: $isPrimary)
|
||||||
.font(.subheadline)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit Button
|
if let errorMessage = viewModel.errorMessage {
|
||||||
Button(action: submitForm) {
|
Section {
|
||||||
HStack {
|
Text(errorMessage)
|
||||||
if viewModel.isLoading {
|
.foregroundColor(.red)
|
||||||
ProgressView()
|
.font(.caption)
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
||||||
} else {
|
|
||||||
Text("Update Property")
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(viewModel.isLoading ? Color.gray : Color.blue)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.cornerRadius(12)
|
|
||||||
}
|
|
||||||
.disabled(viewModel.isLoading)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.navigationTitle("Edit Residence")
|
.navigationTitle("Edit Residence")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -245,24 +156,17 @@ struct EditResidenceView: View {
|
|||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Save") {
|
||||||
|
submitForm()
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isLoading)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showPropertyTypePicker) {
|
|
||||||
PropertyTypePickerView(
|
|
||||||
propertyTypes: lookupsManager.residenceTypes,
|
|
||||||
selectedType: $selectedPropertyType,
|
|
||||||
isPresented: $showPropertyTypePicker
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
populateFields()
|
populateFields()
|
||||||
}
|
}
|
||||||
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
|
|
||||||
Button("OK") {
|
|
||||||
viewModel.clearError()
|
|
||||||
}
|
|
||||||
} message: {
|
|
||||||
Text(viewModel.errorMessage ?? "")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import ComposeApp
|
|||||||
struct ResidenceDetailView: View {
|
struct ResidenceDetailView: View {
|
||||||
let residenceId: Int32
|
let residenceId: Int32
|
||||||
@StateObject private var viewModel = ResidenceViewModel()
|
@StateObject private var viewModel = ResidenceViewModel()
|
||||||
@State private var residenceWithTasks: ResidenceWithTasks?
|
@StateObject private var taskViewModel = TaskViewModel()
|
||||||
|
@State private var tasksResponse: TasksByResidenceResponse?
|
||||||
@State private var isLoadingTasks = false
|
@State private var isLoadingTasks = false
|
||||||
@State private var tasksError: String?
|
@State private var tasksError: String?
|
||||||
@State private var showAddTask = false
|
@State private var showAddTask = false
|
||||||
@State private var showEditResidence = false
|
@State private var showEditResidence = false
|
||||||
|
@State private var showEditTask = false
|
||||||
|
@State private var selectedTaskForEdit: TaskDetail?
|
||||||
|
@State private var showCancelledTasks = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -30,8 +34,25 @@ struct ResidenceDetailView: View {
|
|||||||
.padding(.top)
|
.padding(.top)
|
||||||
|
|
||||||
// Tasks Section
|
// Tasks Section
|
||||||
if let residenceWithTasks = residenceWithTasks {
|
if let tasksResponse = tasksResponse {
|
||||||
TasksSection(residenceWithTasks: residenceWithTasks)
|
TasksSection(
|
||||||
|
tasksResponse: tasksResponse,
|
||||||
|
showCancelledTasks: $showCancelledTasks,
|
||||||
|
onEditTask: { task in
|
||||||
|
selectedTaskForEdit = task
|
||||||
|
showEditTask = true
|
||||||
|
},
|
||||||
|
onCancelTask: { task in
|
||||||
|
taskViewModel.cancelTask(id: task.id) { _ in
|
||||||
|
loadResidenceTasks()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUncancelTask: { task in
|
||||||
|
taskViewModel.uncancelTask(id: task.id) { _ in
|
||||||
|
loadResidenceTasks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
} else if isLoadingTasks {
|
} else if isLoadingTasks {
|
||||||
ProgressView("Loading tasks...")
|
ProgressView("Loading tasks...")
|
||||||
@@ -74,18 +95,26 @@ struct ResidenceDetailView: View {
|
|||||||
EditResidenceView(residence: residence, isPresented: $showEditResidence)
|
EditResidenceView(residence: residence, isPresented: $showEditResidence)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showEditTask) {
|
||||||
|
if let task = selectedTaskForEdit {
|
||||||
|
EditTaskView(task: task, isPresented: $showEditTask)
|
||||||
|
}
|
||||||
|
}
|
||||||
.onChange(of: showAddTask) { isShowing in
|
.onChange(of: showAddTask) { isShowing in
|
||||||
if !isShowing {
|
if !isShowing {
|
||||||
// Refresh tasks when sheet is dismissed
|
loadResidenceTasks()
|
||||||
loadResidenceWithTasks()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: showEditResidence) { isShowing in
|
.onChange(of: showEditResidence) { isShowing in
|
||||||
if !isShowing {
|
if !isShowing {
|
||||||
// Refresh residence data when edit sheet is dismissed
|
|
||||||
loadResidenceData()
|
loadResidenceData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: showEditTask) { isShowing in
|
||||||
|
if !isShowing {
|
||||||
|
loadResidenceTasks()
|
||||||
|
}
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
loadResidenceData()
|
loadResidenceData()
|
||||||
}
|
}
|
||||||
@@ -93,21 +122,19 @@ struct ResidenceDetailView: View {
|
|||||||
|
|
||||||
private func loadResidenceData() {
|
private func loadResidenceData() {
|
||||||
viewModel.getResidence(id: residenceId)
|
viewModel.getResidence(id: residenceId)
|
||||||
loadResidenceWithTasks()
|
loadResidenceTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadResidenceWithTasks() {
|
private func loadResidenceTasks() {
|
||||||
guard let token = TokenStorage().getToken() else { return }
|
guard let token = TokenStorage().getToken() else { return }
|
||||||
|
|
||||||
isLoadingTasks = true
|
isLoadingTasks = true
|
||||||
tasksError = nil
|
tasksError = nil
|
||||||
|
|
||||||
let residenceApi = ResidenceApi(client: ApiClient_iosKt.createHttpClient())
|
let taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient())
|
||||||
residenceApi.getMyResidences(token: token) { result, error in
|
taskApi.getTasksByResidence(token: token, residenceId: residenceId) { result, error in
|
||||||
if let successResult = result as? ApiResultSuccess<MyResidencesResponse> {
|
if let successResult = result as? ApiResultSuccess<TasksByResidenceResponse> {
|
||||||
if let residence = successResult.data?.residences.first(where: { $0.id == residenceId }) {
|
self.tasksResponse = successResult.data
|
||||||
self.residenceWithTasks = residence
|
|
||||||
}
|
|
||||||
self.isLoadingTasks = false
|
self.isLoadingTasks = false
|
||||||
} else if let errorResult = result as? ApiResultError {
|
} else if let errorResult = result as? ApiResultError {
|
||||||
self.tasksError = errorResult.message
|
self.tasksError = errorResult.message
|
||||||
@@ -175,14 +202,6 @@ struct PropertyHeaderCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if !residence.description.isEmpty {
|
|
||||||
// Divider()
|
|
||||||
//
|
|
||||||
// Text(residence.)
|
|
||||||
// .font(.body)
|
|
||||||
// .foregroundColor(.secondary)
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.background(Color.blue.opacity(0.1))
|
.background(Color.blue.opacity(0.1))
|
||||||
@@ -213,7 +232,11 @@ struct PropertyDetailItem: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct TasksSection: View {
|
struct TasksSection: View {
|
||||||
let residenceWithTasks: ResidenceWithTasks
|
let tasksResponse: TasksByResidenceResponse
|
||||||
|
@Binding var showCancelledTasks: Bool
|
||||||
|
let onEditTask: (TaskDetail) -> Void
|
||||||
|
let onCancelTask: (TaskDetail) -> Void
|
||||||
|
let onUncancelTask: (TaskDetail) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -226,17 +249,53 @@ struct TasksSection: View {
|
|||||||
|
|
||||||
// Task Summary Pills
|
// Task Summary Pills
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
TaskPill(count: residenceWithTasks.taskSummary.total, label: "Total", color: .blue)
|
TaskPill(count: tasksResponse.summary.total, label: "Total", color: .blue)
|
||||||
TaskPill(count: residenceWithTasks.taskSummary.pending, label: "Pending", color: .orange)
|
TaskPill(count: tasksResponse.summary.pending, label: "Pending", color: .orange)
|
||||||
TaskPill(count: residenceWithTasks.taskSummary.completed, label: "Done", color: .green)
|
TaskPill(count: tasksResponse.summary.completed, label: "Done", color: .green)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if residenceWithTasks.tasks.isEmpty {
|
// Active Tasks
|
||||||
|
if tasksResponse.tasks.isEmpty && tasksResponse.cancelledTasks.isEmpty {
|
||||||
EmptyTasksView()
|
EmptyTasksView()
|
||||||
} else {
|
} else {
|
||||||
ForEach(residenceWithTasks.tasks, id: \.id) { task in
|
ForEach(tasksResponse.tasks, id: \.id) { task in
|
||||||
TaskCard(task: task)
|
TaskCard(
|
||||||
|
task: task,
|
||||||
|
onEdit: { onEditTask(task) },
|
||||||
|
onCancel: { onCancelTask(task) },
|
||||||
|
onUncancel: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancelled Tasks Section
|
||||||
|
if !tasksResponse.cancelledTasks.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Label("Cancelled Tasks (\(tasksResponse.cancelledTasks.count))", systemImage: "xmark.circle")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(showCancelledTasks ? "Hide" : "Show") {
|
||||||
|
showCancelledTasks.toggle()
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
if showCancelledTasks {
|
||||||
|
ForEach(tasksResponse.cancelledTasks, id: \.id) { task in
|
||||||
|
TaskCard(
|
||||||
|
task: task,
|
||||||
|
onEdit: { onEditTask(task) },
|
||||||
|
onCancel: nil,
|
||||||
|
onUncancel: { onUncancelTask(task) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,6 +326,9 @@ struct TaskPill: View {
|
|||||||
|
|
||||||
struct TaskCard: View {
|
struct TaskCard: View {
|
||||||
let task: TaskDetail
|
let task: TaskDetail
|
||||||
|
let onEdit: () -> Void
|
||||||
|
let onCancel: (() -> Void)?
|
||||||
|
let onUncancel: (() -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -294,14 +356,12 @@ struct TaskCard: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
|
|
||||||
Label(task.frequency.displayName, systemImage: "repeat")
|
Label(task.frequency.displayName, systemImage: "repeat")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
|
||||||
Label(formatDate(task.dueDate), systemImage: "calendar")
|
Label(formatDate(task.dueDate), systemImage: "calendar")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@@ -318,69 +378,49 @@ struct TaskCard: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(task.completions, id: \.id) { completion in
|
|
||||||
Spacer().frame(height: 12)
|
|
||||||
|
|
||||||
// Card equivalent
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
// Top row: date + rating badge
|
|
||||||
HStack {
|
|
||||||
Text(completion.completionDate.components(separatedBy: "T").first ?? "")
|
|
||||||
.font(.body.weight(.bold))
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if let rating = completion.rating {
|
|
||||||
Text("\(rating)★")
|
|
||||||
.font(.caption.weight(.bold))
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(Color(.tertiarySystemFill))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Completed by
|
|
||||||
if let name = completion.completedByName {
|
|
||||||
Text("By: \(name)")
|
|
||||||
.font(.subheadline.weight(.medium))
|
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cost
|
|
||||||
if let cost = completion.actualCost {
|
|
||||||
Text("Cost: $\(cost)")
|
|
||||||
.font(.subheadline.weight(.medium))
|
|
||||||
.foregroundColor(.teal) // tertiary equivalent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(16)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(Color.secondary.opacity(0.15)) // surfaceVariant equivalent
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if task.showCompletedButton {
|
if task.showCompletedButton {
|
||||||
Button(action: {}) {
|
Button(action: {}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "checkmark.circle.fill") // SF Symbol
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 20, height: 20)
|
.frame(width: 20, height: 20)
|
||||||
Spacer().frame(width: 8)
|
|
||||||
Text("Complete Task")
|
Text("Complete Task")
|
||||||
.font(.title3.weight(.semibold)) // ≈ Material titleSmall + SemiBold
|
.font(.title3.weight(.semibold))
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action Buttons
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button(action: onEdit) {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
.font(.subheadline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
if let onCancel = onCancel {
|
||||||
|
Button(action: onCancel) {
|
||||||
|
Label("Cancel", systemImage: "xmark.circle")
|
||||||
|
.font(.subheadline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.red)
|
||||||
|
} else if let onUncancel = onUncancel {
|
||||||
|
Button(action: onUncancel) {
|
||||||
|
Label("Restore", systemImage: "arrow.uturn.backward")
|
||||||
|
.font(.subheadline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.blue)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent) // gives filled look
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ struct AddTaskView: View {
|
|||||||
// Validation errors
|
// Validation errors
|
||||||
@State private var titleError: String = ""
|
@State private var titleError: String = ""
|
||||||
|
|
||||||
// Picker states
|
|
||||||
@State private var showCategoryPicker = false
|
|
||||||
@State private var showFrequencyPicker = false
|
|
||||||
@State private var showPriorityPicker = false
|
|
||||||
@State private var showStatusPicker = false
|
|
||||||
|
|
||||||
enum Field {
|
enum Field {
|
||||||
case title, description, intervalDays, estimatedCost
|
case title, description, intervalDays, estimatedCost
|
||||||
@@ -34,10 +29,6 @@ struct AddTaskView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
ZStack {
|
|
||||||
Color(.systemGroupedBackground)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
if lookupsManager.isLoading {
|
if lookupsManager.isLoading {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -45,22 +36,9 @@ struct AddTaskView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
Form {
|
||||||
VStack(spacing: 24) {
|
Section(header: Text("Task Details")) {
|
||||||
// Task Information Section
|
TextField("Title", text: $title)
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
Text("Task Information")
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
|
|
||||||
// Title Field
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Task Title *")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
TextField("e.g., Clean gutters", text: $title)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.focused($focusedField, equals: .title)
|
.focused($focusedField, equals: .title)
|
||||||
|
|
||||||
if !titleError.isEmpty {
|
if !titleError.isEmpty {
|
||||||
@@ -68,133 +46,65 @@ struct AddTaskView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TextField("Description (optional)", text: $description, axis: .vertical)
|
||||||
|
.lineLimit(3...6)
|
||||||
|
.focused($focusedField, equals: .description)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Description Field
|
Section(header: Text("Category")) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
Picker("Category", selection: $selectedCategory) {
|
||||||
Text("Description (Optional)")
|
Text("Select Category").tag(nil as TaskCategory?)
|
||||||
.font(.subheadline)
|
ForEach(lookupsManager.taskCategories, id: \.id) { category in
|
||||||
.foregroundColor(.secondary)
|
Text(category.name.capitalized).tag(category as TaskCategory?)
|
||||||
|
}
|
||||||
TextEditor(text: $description)
|
}
|
||||||
.frame(height: 100)
|
|
||||||
.padding(8)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.cornerRadius(8)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category Picker
|
Section(header: Text("Scheduling")) {
|
||||||
PickerField(
|
Picker("Frequency", selection: $selectedFrequency) {
|
||||||
label: "Category *",
|
Text("Select Frequency").tag(nil as TaskFrequency?)
|
||||||
selectedItem: selectedCategory?.name ?? "Select Category",
|
ForEach(lookupsManager.taskFrequencies, id: \.id) { frequency in
|
||||||
showPicker: $showCategoryPicker
|
Text(frequency.displayName).tag(frequency as TaskFrequency?)
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Frequency Picker
|
|
||||||
PickerField(
|
|
||||||
label: "Frequency *",
|
|
||||||
selectedItem: selectedFrequency?.displayName ?? "Select Frequency",
|
|
||||||
showPicker: $showFrequencyPicker
|
|
||||||
)
|
|
||||||
|
|
||||||
// Interval Days (if applicable)
|
|
||||||
if selectedFrequency?.name != "once" {
|
if selectedFrequency?.name != "once" {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
TextField("Custom Interval (days, optional)", text: $intervalDays)
|
||||||
Text("Custom Interval (days, optional)")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
TextField("Leave empty for default", text: $intervalDays)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.keyboardType(.numberPad)
|
.keyboardType(.numberPad)
|
||||||
.focused($focusedField, equals: .intervalDays)
|
.focused($focusedField, equals: .intervalDays)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DatePicker("Due Date", selection: $dueDate, displayedComponents: .date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority Picker
|
Section(header: Text("Priority & Status")) {
|
||||||
PickerField(
|
Picker("Priority", selection: $selectedPriority) {
|
||||||
label: "Priority *",
|
Text("Select Priority").tag(nil as TaskPriority?)
|
||||||
selectedItem: selectedPriority?.displayName ?? "Select Priority",
|
ForEach(lookupsManager.taskPriorities, id: \.id) { priority in
|
||||||
showPicker: $showPriorityPicker
|
Text(priority.displayName).tag(priority as TaskPriority?)
|
||||||
)
|
}
|
||||||
|
|
||||||
// Status Picker
|
|
||||||
PickerField(
|
|
||||||
label: "Status *",
|
|
||||||
selectedItem: selectedStatus?.displayName ?? "Select Status",
|
|
||||||
showPicker: $showStatusPicker
|
|
||||||
)
|
|
||||||
|
|
||||||
// Due Date Picker
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Due Date *")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
DatePicker("", selection: $dueDate, displayedComponents: .date)
|
|
||||||
.datePickerStyle(.compact)
|
|
||||||
.labelsHidden()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Estimated Cost Field
|
Picker("Status", selection: $selectedStatus) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
Text("Select Status").tag(nil as TaskStatus?)
|
||||||
Text("Estimated Cost (Optional)")
|
ForEach(lookupsManager.taskStatuses, id: \.id) { status in
|
||||||
.font(.subheadline)
|
Text(status.displayName).tag(status as TaskStatus?)
|
||||||
.foregroundColor(.secondary)
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TextField("e.g., 150.00", text: $estimatedCost)
|
Section(header: Text("Cost")) {
|
||||||
.textFieldStyle(.roundedBorder)
|
TextField("Estimated Cost (optional)", text: $estimatedCost)
|
||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
.focused($focusedField, equals: .estimatedCost)
|
.focused($focusedField, equals: .estimatedCost)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Error Message
|
|
||||||
if let errorMessage = viewModel.errorMessage {
|
if let errorMessage = viewModel.errorMessage {
|
||||||
HStack {
|
Section {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
|
|
||||||
Text(errorMessage)
|
Text(errorMessage)
|
||||||
|
.foregroundColor(.red)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.red)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: viewModel.clearError) {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.background(Color.red.opacity(0.1))
|
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Submit Button
|
|
||||||
Button(action: submitForm) {
|
|
||||||
HStack {
|
|
||||||
if viewModel.isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
||||||
} else {
|
|
||||||
Text("Create Task")
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(viewModel.isLoading ? Color.gray : Color.blue)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.cornerRadius(12)
|
|
||||||
}
|
|
||||||
.disabled(viewModel.isLoading)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,50 +116,13 @@ struct AddTaskView: View {
|
|||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Save") {
|
||||||
|
submitForm()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showCategoryPicker) {
|
.disabled(viewModel.isLoading)
|
||||||
LookupPickerView(
|
|
||||||
title: "Select Category",
|
|
||||||
items: lookupsManager.taskCategories.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.name) },
|
|
||||||
selectedId: selectedCategory?.id,
|
|
||||||
isPresented: $showCategoryPicker,
|
|
||||||
onSelect: { id in
|
|
||||||
selectedCategory = lookupsManager.taskCategories.first { $0.id == id }
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showFrequencyPicker) {
|
|
||||||
LookupPickerView(
|
|
||||||
title: "Select Frequency",
|
|
||||||
items: lookupsManager.taskFrequencies.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.displayName) },
|
|
||||||
selectedId: selectedFrequency?.id,
|
|
||||||
isPresented: $showFrequencyPicker,
|
|
||||||
onSelect: { id in
|
|
||||||
selectedFrequency = lookupsManager.taskFrequencies.first { $0.id == id }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showPriorityPicker) {
|
|
||||||
LookupPickerView(
|
|
||||||
title: "Select Priority",
|
|
||||||
items: lookupsManager.taskPriorities.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.displayName) },
|
|
||||||
selectedId: selectedPriority?.id,
|
|
||||||
isPresented: $showPriorityPicker,
|
|
||||||
onSelect: { id in
|
|
||||||
selectedPriority = lookupsManager.taskPriorities.first { $0.id == id }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showStatusPicker) {
|
|
||||||
LookupPickerView(
|
|
||||||
title: "Select Status",
|
|
||||||
items: lookupsManager.taskStatuses.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.displayName) },
|
|
||||||
selectedId: selectedStatus?.id,
|
|
||||||
isPresented: $showStatusPicker,
|
|
||||||
onSelect: { id in
|
|
||||||
selectedStatus = lookupsManager.taskStatuses.first { $0.id == id }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
setDefaults()
|
setDefaults()
|
||||||
@@ -261,6 +134,7 @@ struct AddTaskView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func setDefaults() {
|
private func setDefaults() {
|
||||||
// Set default values if not already set
|
// Set default values if not already set
|
||||||
@@ -353,79 +227,6 @@ struct AddTaskView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Supporting Views
|
|
||||||
struct PickerField: View {
|
|
||||||
let label: String
|
|
||||||
let selectedItem: String
|
|
||||||
@Binding var showPicker: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(label)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
showPicker = true
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Text(selectedItem)
|
|
||||||
.foregroundColor(selectedItem.contains("Select") ? .gray : .primary)
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LookupItem: Identifiable {
|
|
||||||
let id: Int32
|
|
||||||
let name: String
|
|
||||||
let displayName: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LookupPickerView: View {
|
|
||||||
let title: String
|
|
||||||
let items: [LookupItem]
|
|
||||||
let selectedId: Int32?
|
|
||||||
@Binding var isPresented: Bool
|
|
||||||
let onSelect: (Int32) -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationView {
|
|
||||||
List(items) { item in
|
|
||||||
Button(action: {
|
|
||||||
onSelect(item.id)
|
|
||||||
isPresented = false
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Text(item.displayName)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Spacer()
|
|
||||||
if selectedId == item.id {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle(title)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button("Done") {
|
|
||||||
isPresented = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
AddTaskView(residenceId: 1, isPresented: .constant(true))
|
AddTaskView(residenceId: 1, isPresented: .constant(true))
|
||||||
|
|||||||
163
iosApp/iosApp/Task/EditTaskView.swift
Normal file
163
iosApp/iosApp/Task/EditTaskView.swift
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ComposeApp
|
||||||
|
|
||||||
|
struct EditTaskView: View {
|
||||||
|
let task: TaskDetail
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
|
||||||
|
@StateObject private var viewModel = TaskViewModel()
|
||||||
|
@StateObject private var lookupsManager = LookupsManager.shared
|
||||||
|
|
||||||
|
@State private var title: String
|
||||||
|
@State private var description: String
|
||||||
|
@State private var selectedCategory: TaskCategory?
|
||||||
|
@State private var selectedFrequency: TaskFrequency?
|
||||||
|
@State private var selectedPriority: TaskPriority?
|
||||||
|
@State private var selectedStatus: TaskStatus?
|
||||||
|
@State private var dueDate: String
|
||||||
|
@State private var estimatedCost: String
|
||||||
|
|
||||||
|
@State private var showAlert = false
|
||||||
|
@State private var alertMessage = ""
|
||||||
|
|
||||||
|
init(task: TaskDetail, isPresented: Binding<Bool>) {
|
||||||
|
self.task = task
|
||||||
|
self._isPresented = isPresented
|
||||||
|
|
||||||
|
// Initialize state from task
|
||||||
|
_title = State(initialValue: task.title)
|
||||||
|
_description = State(initialValue: task.description ?? "")
|
||||||
|
_selectedCategory = State(initialValue: task.category)
|
||||||
|
_selectedFrequency = State(initialValue: task.frequency)
|
||||||
|
_selectedPriority = State(initialValue: task.priority)
|
||||||
|
_selectedStatus = State(initialValue: task.status)
|
||||||
|
_dueDate = State(initialValue: task.dueDate)
|
||||||
|
_estimatedCost = State(initialValue: task.estimatedCost ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
Form {
|
||||||
|
Section(header: Text("Task Details")) {
|
||||||
|
TextField("Title", text: $title)
|
||||||
|
|
||||||
|
TextField("Description", text: $description, axis: .vertical)
|
||||||
|
.lineLimit(3...6)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Category")) {
|
||||||
|
Picker("Category", selection: $selectedCategory) {
|
||||||
|
ForEach(lookupsManager.taskCategories, id: \.id) { category in
|
||||||
|
Text(category.name.capitalized).tag(category as TaskCategory?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Scheduling")) {
|
||||||
|
Picker("Frequency", selection: $selectedFrequency) {
|
||||||
|
ForEach(lookupsManager.taskFrequencies, id: \.id) { frequency in
|
||||||
|
Text(frequency.name.capitalized).tag(frequency as TaskFrequency?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("Due Date (YYYY-MM-DD)", text: $dueDate)
|
||||||
|
.keyboardType(.numbersAndPunctuation)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Priority & Status")) {
|
||||||
|
Picker("Priority", selection: $selectedPriority) {
|
||||||
|
ForEach(lookupsManager.taskPriorities, id: \.id) { priority in
|
||||||
|
Text(priority.name.capitalized).tag(priority as TaskPriority?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("Status", selection: $selectedStatus) {
|
||||||
|
ForEach(lookupsManager.taskStatuses, id: \.id) { status in
|
||||||
|
Text(status.name.capitalized).tag(status as TaskStatus?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Cost")) {
|
||||||
|
TextField("Estimated Cost", text: $estimatedCost)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
Section {
|
||||||
|
Text(errorMessage)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Edit Task")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Cancel") {
|
||||||
|
isPresented = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Save") {
|
||||||
|
submitForm()
|
||||||
|
}
|
||||||
|
.disabled(!isFormValid())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Success", isPresented: $showAlert) {
|
||||||
|
Button("OK") {
|
||||||
|
isPresented = false
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(alertMessage)
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.taskUpdated) { updated in
|
||||||
|
if updated {
|
||||||
|
alertMessage = "Task updated successfully"
|
||||||
|
showAlert = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isFormValid() -> Bool {
|
||||||
|
return !title.isEmpty &&
|
||||||
|
selectedCategory != nil &&
|
||||||
|
selectedFrequency != nil &&
|
||||||
|
selectedPriority != nil &&
|
||||||
|
selectedStatus != nil &&
|
||||||
|
!dueDate.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submitForm() {
|
||||||
|
guard isFormValid(),
|
||||||
|
let category = selectedCategory,
|
||||||
|
let frequency = selectedFrequency,
|
||||||
|
let priority = selectedPriority,
|
||||||
|
let status = selectedStatus else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = TaskCreateRequest(
|
||||||
|
residence: task.residence,
|
||||||
|
title: title,
|
||||||
|
description: description.isEmpty ? nil : description,
|
||||||
|
category: category.id,
|
||||||
|
frequency: frequency.id,
|
||||||
|
intervalDays: nil,
|
||||||
|
priority: priority.id,
|
||||||
|
status: status.id,
|
||||||
|
dueDate: dueDate,
|
||||||
|
estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.updateTask(id: task.id, request: request) { success in
|
||||||
|
if !success {
|
||||||
|
// Error is already set in viewModel.errorMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ class TaskViewModel: ObservableObject {
|
|||||||
@Published var isLoading: Bool = false
|
@Published var isLoading: Bool = false
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
@Published var taskCreated: Bool = false
|
@Published var taskCreated: Bool = false
|
||||||
|
@Published var taskUpdated: Bool = false
|
||||||
|
@Published var taskCancelled: Bool = false
|
||||||
|
@Published var taskUncancelled: Bool = false
|
||||||
|
|
||||||
// MARK: - Private Properties
|
// MARK: - Private Properties
|
||||||
private let taskApi: TaskApi
|
private let taskApi: TaskApi
|
||||||
@@ -49,12 +52,99 @@ class TaskViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateTask(id: Int32, request: TaskCreateRequest, completion: @escaping (Bool) -> Void) {
|
||||||
|
guard let token = tokenStorage.getToken() else {
|
||||||
|
errorMessage = "Not authenticated"
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
taskUpdated = false
|
||||||
|
|
||||||
|
taskApi.updateTask(token: token, id: id, request: request) { result, error in
|
||||||
|
if result is ApiResultSuccess<CustomTask> {
|
||||||
|
self.isLoading = false
|
||||||
|
self.taskUpdated = true
|
||||||
|
completion(true)
|
||||||
|
} else if let errorResult = result as? ApiResultError {
|
||||||
|
self.errorMessage = errorResult.message
|
||||||
|
self.isLoading = false
|
||||||
|
completion(false)
|
||||||
|
} else if let error = error {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isLoading = false
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelTask(id: Int32, completion: @escaping (Bool) -> Void) {
|
||||||
|
guard let token = tokenStorage.getToken() else {
|
||||||
|
errorMessage = "Not authenticated"
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
taskCancelled = false
|
||||||
|
|
||||||
|
taskApi.cancelTask(token: token, id: id) { result, error in
|
||||||
|
if result is ApiResultSuccess<TaskCancelResponse> {
|
||||||
|
self.isLoading = false
|
||||||
|
self.taskCancelled = true
|
||||||
|
completion(true)
|
||||||
|
} else if let errorResult = result as? ApiResultError {
|
||||||
|
self.errorMessage = errorResult.message
|
||||||
|
self.isLoading = false
|
||||||
|
completion(false)
|
||||||
|
} else if let error = error {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isLoading = false
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uncancelTask(id: Int32, completion: @escaping (Bool) -> Void) {
|
||||||
|
guard let token = tokenStorage.getToken() else {
|
||||||
|
errorMessage = "Not authenticated"
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
taskUncancelled = false
|
||||||
|
|
||||||
|
taskApi.uncancelTask(token: token, id: id) { result, error in
|
||||||
|
if result is ApiResultSuccess<TaskCancelResponse> {
|
||||||
|
self.isLoading = false
|
||||||
|
self.taskUncancelled = true
|
||||||
|
completion(true)
|
||||||
|
} else if let errorResult = result as? ApiResultError {
|
||||||
|
self.errorMessage = errorResult.message
|
||||||
|
self.isLoading = false
|
||||||
|
completion(false)
|
||||||
|
} else if let error = error {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isLoading = false
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func clearError() {
|
func clearError() {
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetState() {
|
func resetState() {
|
||||||
taskCreated = false
|
taskCreated = false
|
||||||
|
taskUpdated = false
|
||||||
|
taskCancelled = false
|
||||||
|
taskUncancelled = false
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user