wip
This commit is contained in:
@@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.mycrib.android.ui.screens.AddResidenceScreen
|
||||
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.LoginScreen
|
||||
import com.mycrib.android.ui.screens.RegisterScreen
|
||||
@@ -218,9 +219,60 @@ fun App() {
|
||||
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(
|
||||
@SerialName("residence_id") val residenceId: String,
|
||||
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
|
||||
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
|
||||
object TasksRoute
|
||||
|
||||
@@ -109,4 +109,36 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
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,
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToEditResidence: (Residence) -> Unit,
|
||||
onNavigateToEditTask: (TaskDetail) -> Unit,
|
||||
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
|
||||
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
|
||||
taskViewModel: TaskViewModel = viewModel { TaskViewModel() }
|
||||
@@ -36,10 +37,13 @@ fun ResidenceDetailScreen(
|
||||
val tasksState by residenceViewModel.residenceTasksState.collectAsState()
|
||||
val completionState by taskCompletionViewModel.createCompletionState.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 selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
|
||||
var showNewTaskDialog by remember { mutableStateOf(false) }
|
||||
var showCancelledTasks by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(residenceId) {
|
||||
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) {
|
||||
CompleteTaskDialog(
|
||||
taskId = selectedTask!!.id,
|
||||
@@ -362,7 +388,7 @@ fun ResidenceDetailScreen(
|
||||
}
|
||||
is ApiResult.Success -> {
|
||||
val taskData = (tasksState as ApiResult.Success).data
|
||||
if (taskData.tasks.isEmpty()) {
|
||||
if (taskData.tasks.isEmpty() && taskData.cancelledTasks.isEmpty()) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -401,9 +427,65 @@ fun ResidenceDetailScreen(
|
||||
onCompleteClick = {
|
||||
selectedTask = task
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,7 +597,10 @@ private fun DetailRow(
|
||||
@Composable
|
||||
fun TaskCard(
|
||||
task: TaskDetail,
|
||||
onCompleteClick: () -> Unit
|
||||
onCompleteClick: (() -> Unit)?,
|
||||
onEditClick: () -> Unit,
|
||||
onCancelClick: (() -> Unit)?,
|
||||
onUncancelClick: (() -> Unit)?
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -733,7 +818,7 @@ fun TaskCard(
|
||||
}
|
||||
|
||||
// Show complete task button based on API logic
|
||||
if (task.showCompletedButton) {
|
||||
if (task.showCompletedButton && onCompleteClick != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(
|
||||
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)
|
||||
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() {
|
||||
viewModelScope.launch {
|
||||
_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 postalCodeError: String = ""
|
||||
|
||||
// Picker state
|
||||
@State private var showPropertyTypePicker = false
|
||||
|
||||
enum Field {
|
||||
case name, streetAddress, apartmentUnit, city, stateProvince, postalCode, country
|
||||
case bedrooms, bathrooms, squareFootage, lotSize, yearBuilt, description
|
||||
@@ -41,202 +38,116 @@ struct AddResidenceView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
Color(.systemGroupedBackground)
|
||||
.ignoresSafeArea()
|
||||
Form {
|
||||
Section(header: Text("Property Details")) {
|
||||
TextField("Property Name", text: $name)
|
||||
.focused($focusedField, equals: .name)
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Required Information Section
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Required Information")
|
||||
.font(.headline)
|
||||
.foregroundColor(.blue)
|
||||
|
||||
FormTextField(
|
||||
label: "Property Name",
|
||||
text: $name,
|
||||
error: nameError,
|
||||
placeholder: "My Home",
|
||||
focusedField: $focusedField,
|
||||
field: .name
|
||||
)
|
||||
|
||||
// Property Type Picker
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Property Type")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Button(action: {
|
||||
showPropertyTypePicker = true
|
||||
}) {
|
||||
HStack {
|
||||
Text(selectedPropertyType?.name ?? "Select Type")
|
||||
.foregroundColor(selectedPropertyType == nil ? .gray : .primary)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
FormTextField(
|
||||
label: "Street Address",
|
||||
text: $streetAddress,
|
||||
error: streetAddressError,
|
||||
placeholder: "123 Main St",
|
||||
focusedField: $focusedField,
|
||||
field: .streetAddress
|
||||
)
|
||||
|
||||
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
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Optional Information")
|
||||
.font(.headline)
|
||||
.foregroundColor(.blue)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
FormTextField(
|
||||
label: "Bedrooms",
|
||||
text: $bedrooms,
|
||||
error: "",
|
||||
placeholder: "3",
|
||||
focusedField: $focusedField,
|
||||
field: .bedrooms,
|
||||
keyboardType: .numberPad
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
label: "Bathrooms",
|
||||
text: $bathrooms,
|
||||
error: "",
|
||||
placeholder: "2.5",
|
||||
focusedField: $focusedField,
|
||||
field: .bathrooms,
|
||||
keyboardType: .decimalPad
|
||||
)
|
||||
}
|
||||
|
||||
FormTextField(
|
||||
label: "Square Footage",
|
||||
text: $squareFootage,
|
||||
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)
|
||||
.font(.subheadline)
|
||||
}
|
||||
|
||||
// Submit Button
|
||||
Button(action: submitForm) {
|
||||
HStack {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.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)
|
||||
if !nameError.isEmpty {
|
||||
Text(nameError)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
Picker("Property Type", selection: $selectedPropertyType) {
|
||||
Text("Select Type").tag(nil as ResidenceType?)
|
||||
ForEach(lookupsManager.residenceTypes, id: \.id) { type in
|
||||
Text(type.name).tag(type as ResidenceType?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Address")) {
|
||||
TextField("Street Address", text: $streetAddress)
|
||||
.focused($focusedField, equals: .streetAddress)
|
||||
|
||||
if !streetAddressError.isEmpty {
|
||||
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 {
|
||||
Text("Bedrooms")
|
||||
Spacer()
|
||||
TextField("0", text: $bedrooms)
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 60)
|
||||
.focused($focusedField, equals: .bedrooms)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Bathrooms")
|
||||
Spacer()
|
||||
TextField("0.0", text: $bathrooms)
|
||||
.keyboardType(.decimalPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 60)
|
||||
.focused($focusedField, equals: .bathrooms)
|
||||
}
|
||||
|
||||
TextField("Square Footage", text: $squareFootage)
|
||||
.keyboardType(.numberPad)
|
||||
.focused($focusedField, equals: .squareFootage)
|
||||
|
||||
TextField("Lot Size (acres)", text: $lotSize)
|
||||
.keyboardType(.decimalPad)
|
||||
.focused($focusedField, equals: .lotSize)
|
||||
|
||||
TextField("Year Built", text: $yearBuilt)
|
||||
.keyboardType(.numberPad)
|
||||
.focused($focusedField, equals: .yearBuilt)
|
||||
}
|
||||
|
||||
Section(header: Text("Additional Details")) {
|
||||
TextField("Description (optional)", text: $description, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
|
||||
Toggle("Primary Residence", isOn: $isPrimary)
|
||||
}
|
||||
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
Section {
|
||||
Text(errorMessage)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add Residence")
|
||||
@@ -247,13 +158,13 @@ struct AddResidenceView: View {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showPropertyTypePicker) {
|
||||
PropertyTypePickerView(
|
||||
propertyTypes: lookupsManager.residenceTypes,
|
||||
selectedType: $selectedPropertyType,
|
||||
isPresented: $showPropertyTypePicker
|
||||
)
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
submitForm()
|
||||
}
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
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 {
|
||||
AddResidenceView(isPresented: .constant(true))
|
||||
|
||||
@@ -32,209 +32,120 @@ struct EditResidenceView: View {
|
||||
@State private var stateProvinceError: String = ""
|
||||
@State private var postalCodeError: String = ""
|
||||
|
||||
// Picker state
|
||||
@State private var showPropertyTypePicker = false
|
||||
|
||||
typealias Field = AddResidenceView.Field
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
Color(.systemGroupedBackground)
|
||||
.ignoresSafeArea()
|
||||
Form {
|
||||
Section(header: Text("Property Details")) {
|
||||
TextField("Property Name", text: $name)
|
||||
.focused($focusedField, equals: .name)
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Required Information Section
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Required Information")
|
||||
.font(.headline)
|
||||
.foregroundColor(.blue)
|
||||
|
||||
FormTextField(
|
||||
label: "Property Name",
|
||||
text: $name,
|
||||
error: nameError,
|
||||
placeholder: "My Home",
|
||||
focusedField: $focusedField,
|
||||
field: .name
|
||||
)
|
||||
|
||||
// Property Type Picker
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Property Type")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Button(action: {
|
||||
showPropertyTypePicker = true
|
||||
}) {
|
||||
HStack {
|
||||
Text(selectedPropertyType?.name ?? "Select Type")
|
||||
.foregroundColor(selectedPropertyType == nil ? .gray : .primary)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
FormTextField(
|
||||
label: "Street Address",
|
||||
text: $streetAddress,
|
||||
error: streetAddressError,
|
||||
placeholder: "123 Main St",
|
||||
focusedField: $focusedField,
|
||||
field: .streetAddress
|
||||
)
|
||||
|
||||
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
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Optional Information")
|
||||
.font(.headline)
|
||||
.foregroundColor(.blue)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
FormTextField(
|
||||
label: "Bedrooms",
|
||||
text: $bedrooms,
|
||||
error: "",
|
||||
placeholder: "3",
|
||||
focusedField: $focusedField,
|
||||
field: .bedrooms,
|
||||
keyboardType: .numberPad
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
label: "Bathrooms",
|
||||
text: $bathrooms,
|
||||
error: "",
|
||||
placeholder: "2.5",
|
||||
focusedField: $focusedField,
|
||||
field: .bathrooms,
|
||||
keyboardType: .decimalPad
|
||||
)
|
||||
}
|
||||
|
||||
FormTextField(
|
||||
label: "Square Footage",
|
||||
text: $squareFootage,
|
||||
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)
|
||||
.font(.subheadline)
|
||||
}
|
||||
|
||||
// Submit Button
|
||||
Button(action: submitForm) {
|
||||
HStack {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.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)
|
||||
if !nameError.isEmpty {
|
||||
Text(nameError)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
Picker("Property Type", selection: $selectedPropertyType) {
|
||||
Text("Select Type").tag(nil as ResidenceType?)
|
||||
ForEach(lookupsManager.residenceTypes, id: \.id) { type in
|
||||
Text(type.name).tag(type as ResidenceType?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Address")) {
|
||||
TextField("Street Address", text: $streetAddress)
|
||||
.focused($focusedField, equals: .streetAddress)
|
||||
|
||||
if !streetAddressError.isEmpty {
|
||||
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 {
|
||||
Text("Bedrooms")
|
||||
Spacer()
|
||||
TextField("0", text: $bedrooms)
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 60)
|
||||
.focused($focusedField, equals: .bedrooms)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Bathrooms")
|
||||
Spacer()
|
||||
TextField("0.0", text: $bathrooms)
|
||||
.keyboardType(.decimalPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 60)
|
||||
.focused($focusedField, equals: .bathrooms)
|
||||
}
|
||||
|
||||
TextField("Square Footage", text: $squareFootage)
|
||||
.keyboardType(.numberPad)
|
||||
.focused($focusedField, equals: .squareFootage)
|
||||
|
||||
TextField("Lot Size (acres)", text: $lotSize)
|
||||
.keyboardType(.decimalPad)
|
||||
.focused($focusedField, equals: .lotSize)
|
||||
|
||||
TextField("Year Built", text: $yearBuilt)
|
||||
.keyboardType(.numberPad)
|
||||
.focused($focusedField, equals: .yearBuilt)
|
||||
}
|
||||
|
||||
Section(header: Text("Additional Details")) {
|
||||
TextField("Description (optional)", text: $description, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
|
||||
Toggle("Primary Residence", isOn: $isPrimary)
|
||||
}
|
||||
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
Section {
|
||||
Text(errorMessage)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Edit Residence")
|
||||
@@ -245,24 +156,17 @@ struct EditResidenceView: View {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showPropertyTypePicker) {
|
||||
PropertyTypePickerView(
|
||||
propertyTypes: lookupsManager.residenceTypes,
|
||||
selectedType: $selectedPropertyType,
|
||||
isPresented: $showPropertyTypePicker
|
||||
)
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
submitForm()
|
||||
}
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
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 {
|
||||
let residenceId: Int32
|
||||
@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 tasksError: String?
|
||||
@State private var showAddTask = 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 {
|
||||
ZStack {
|
||||
@@ -30,9 +34,26 @@ struct ResidenceDetailView: View {
|
||||
.padding(.top)
|
||||
|
||||
// Tasks Section
|
||||
if let residenceWithTasks = residenceWithTasks {
|
||||
TasksSection(residenceWithTasks: residenceWithTasks)
|
||||
.padding(.horizontal)
|
||||
if let tasksResponse = tasksResponse {
|
||||
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)
|
||||
} else if isLoadingTasks {
|
||||
ProgressView("Loading tasks...")
|
||||
} else if let tasksError = tasksError {
|
||||
@@ -74,18 +95,26 @@ struct ResidenceDetailView: View {
|
||||
EditResidenceView(residence: residence, isPresented: $showEditResidence)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showEditTask) {
|
||||
if let task = selectedTaskForEdit {
|
||||
EditTaskView(task: task, isPresented: $showEditTask)
|
||||
}
|
||||
}
|
||||
.onChange(of: showAddTask) { isShowing in
|
||||
if !isShowing {
|
||||
// Refresh tasks when sheet is dismissed
|
||||
loadResidenceWithTasks()
|
||||
loadResidenceTasks()
|
||||
}
|
||||
}
|
||||
.onChange(of: showEditResidence) { isShowing in
|
||||
if !isShowing {
|
||||
// Refresh residence data when edit sheet is dismissed
|
||||
loadResidenceData()
|
||||
}
|
||||
}
|
||||
.onChange(of: showEditTask) { isShowing in
|
||||
if !isShowing {
|
||||
loadResidenceTasks()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadResidenceData()
|
||||
}
|
||||
@@ -93,21 +122,19 @@ struct ResidenceDetailView: View {
|
||||
|
||||
private func loadResidenceData() {
|
||||
viewModel.getResidence(id: residenceId)
|
||||
loadResidenceWithTasks()
|
||||
loadResidenceTasks()
|
||||
}
|
||||
|
||||
private func loadResidenceWithTasks() {
|
||||
private func loadResidenceTasks() {
|
||||
guard let token = TokenStorage().getToken() else { return }
|
||||
|
||||
isLoadingTasks = true
|
||||
tasksError = nil
|
||||
|
||||
let residenceApi = ResidenceApi(client: ApiClient_iosKt.createHttpClient())
|
||||
residenceApi.getMyResidences(token: token) { result, error in
|
||||
if let successResult = result as? ApiResultSuccess<MyResidencesResponse> {
|
||||
if let residence = successResult.data?.residences.first(where: { $0.id == residenceId }) {
|
||||
self.residenceWithTasks = residence
|
||||
}
|
||||
let taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient())
|
||||
taskApi.getTasksByResidence(token: token, residenceId: residenceId) { result, error in
|
||||
if let successResult = result as? ApiResultSuccess<TasksByResidenceResponse> {
|
||||
self.tasksResponse = successResult.data
|
||||
self.isLoadingTasks = false
|
||||
} else if let errorResult = result as? ApiResultError {
|
||||
self.tasksError = errorResult.message
|
||||
@@ -175,14 +202,6 @@ struct PropertyHeaderCard: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if !residence.description.isEmpty {
|
||||
// Divider()
|
||||
//
|
||||
// Text(residence.)
|
||||
// .font(.body)
|
||||
// .foregroundColor(.secondary)
|
||||
// }
|
||||
}
|
||||
.padding(20)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
@@ -213,7 +232,11 @@ struct PropertyDetailItem: 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 {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@@ -226,17 +249,53 @@ struct TasksSection: View {
|
||||
|
||||
// Task Summary Pills
|
||||
HStack(spacing: 8) {
|
||||
TaskPill(count: residenceWithTasks.taskSummary.total, label: "Total", color: .blue)
|
||||
TaskPill(count: residenceWithTasks.taskSummary.pending, label: "Pending", color: .orange)
|
||||
TaskPill(count: residenceWithTasks.taskSummary.completed, label: "Done", color: .green)
|
||||
TaskPill(count: tasksResponse.summary.total, label: "Total", color: .blue)
|
||||
TaskPill(count: tasksResponse.summary.pending, label: "Pending", color: .orange)
|
||||
TaskPill(count: tasksResponse.summary.completed, label: "Done", color: .green)
|
||||
}
|
||||
}
|
||||
|
||||
if residenceWithTasks.tasks.isEmpty {
|
||||
// Active Tasks
|
||||
if tasksResponse.tasks.isEmpty && tasksResponse.cancelledTasks.isEmpty {
|
||||
EmptyTasksView()
|
||||
} else {
|
||||
ForEach(residenceWithTasks.tasks, id: \.id) { task in
|
||||
TaskCard(task: task)
|
||||
ForEach(tasksResponse.tasks, id: \.id) { task in
|
||||
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 {
|
||||
let task: TaskDetail
|
||||
let onEdit: () -> Void
|
||||
let onCancel: (() -> Void)?
|
||||
let onUncancel: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@@ -283,7 +345,7 @@ struct TaskCard: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
PriorityBadge(priority: task.priority.name)
|
||||
PriorityBadge(priority: task.priority.name)
|
||||
}
|
||||
|
||||
if let description = task.description_, !description.isEmpty {
|
||||
@@ -294,17 +356,15 @@ struct TaskCard: View {
|
||||
}
|
||||
|
||||
HStack {
|
||||
|
||||
Label(task.frequency.displayName, systemImage: "repeat")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Label(task.frequency.displayName, systemImage: "repeat")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
Label(formatDate(task.dueDate), systemImage: "calendar")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Label(formatDate(task.dueDate), systemImage: "calendar")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
// Completion count
|
||||
@@ -318,69 +378,49 @@ struct TaskCard: View {
|
||||
.font(.caption)
|
||||
.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 {
|
||||
Button(action: {}) {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill") // SF Symbol
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
Spacer().frame(width: 8)
|
||||
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)
|
||||
|
||||
@@ -22,11 +22,6 @@ struct AddTaskView: View {
|
||||
// Validation errors
|
||||
@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 {
|
||||
case title, description, intervalDays, estimatedCost
|
||||
@@ -34,231 +29,110 @@ struct AddTaskView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
Color(.systemGroupedBackground)
|
||||
.ignoresSafeArea()
|
||||
if lookupsManager.isLoading {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text("Loading lookup data...")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
Form {
|
||||
Section(header: Text("Task Details")) {
|
||||
TextField("Title", text: $title)
|
||||
.focused($focusedField, equals: .title)
|
||||
|
||||
if lookupsManager.isLoading {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text("Loading lookup data...")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Task Information Section
|
||||
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)
|
||||
|
||||
if !titleError.isEmpty {
|
||||
Text(titleError)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
// Description Field
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Description (Optional)")
|
||||
.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)
|
||||
)
|
||||
}
|
||||
|
||||
// Category Picker
|
||||
PickerField(
|
||||
label: "Category *",
|
||||
selectedItem: selectedCategory?.name ?? "Select Category",
|
||||
showPicker: $showCategoryPicker
|
||||
)
|
||||
|
||||
// Frequency Picker
|
||||
PickerField(
|
||||
label: "Frequency *",
|
||||
selectedItem: selectedFrequency?.displayName ?? "Select Frequency",
|
||||
showPicker: $showFrequencyPicker
|
||||
)
|
||||
|
||||
// Interval Days (if applicable)
|
||||
if selectedFrequency?.name != "once" {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Custom Interval (days, optional)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("Leave empty for default", text: $intervalDays)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.keyboardType(.numberPad)
|
||||
.focused($focusedField, equals: .intervalDays)
|
||||
}
|
||||
}
|
||||
|
||||
// Priority Picker
|
||||
PickerField(
|
||||
label: "Priority *",
|
||||
selectedItem: selectedPriority?.displayName ?? "Select Priority",
|
||||
showPicker: $showPriorityPicker
|
||||
)
|
||||
|
||||
// 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
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Estimated Cost (Optional)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("e.g., 150.00", text: $estimatedCost)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.keyboardType(.decimalPad)
|
||||
.focused($focusedField, equals: .estimatedCost)
|
||||
}
|
||||
}
|
||||
|
||||
// Error Message
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.red)
|
||||
|
||||
Text(errorMessage)
|
||||
.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)
|
||||
if !titleError.isEmpty {
|
||||
Text(titleError)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
TextField("Description (optional)", text: $description, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
.focused($focusedField, equals: .description)
|
||||
}
|
||||
|
||||
Section(header: Text("Category")) {
|
||||
Picker("Category", selection: $selectedCategory) {
|
||||
Text("Select Category").tag(nil as TaskCategory?)
|
||||
ForEach(lookupsManager.taskCategories, id: \.id) { category in
|
||||
Text(category.name.capitalized).tag(category as TaskCategory?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Scheduling")) {
|
||||
Picker("Frequency", selection: $selectedFrequency) {
|
||||
Text("Select Frequency").tag(nil as TaskFrequency?)
|
||||
ForEach(lookupsManager.taskFrequencies, id: \.id) { frequency in
|
||||
Text(frequency.displayName).tag(frequency as TaskFrequency?)
|
||||
}
|
||||
}
|
||||
|
||||
if selectedFrequency?.name != "once" {
|
||||
TextField("Custom Interval (days, optional)", text: $intervalDays)
|
||||
.keyboardType(.numberPad)
|
||||
.focused($focusedField, equals: .intervalDays)
|
||||
}
|
||||
|
||||
DatePicker("Due Date", selection: $dueDate, displayedComponents: .date)
|
||||
}
|
||||
|
||||
Section(header: Text("Priority & Status")) {
|
||||
Picker("Priority", selection: $selectedPriority) {
|
||||
Text("Select Priority").tag(nil as TaskPriority?)
|
||||
ForEach(lookupsManager.taskPriorities, id: \.id) { priority in
|
||||
Text(priority.displayName).tag(priority as TaskPriority?)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Status", selection: $selectedStatus) {
|
||||
Text("Select Status").tag(nil as TaskStatus?)
|
||||
ForEach(lookupsManager.taskStatuses, id: \.id) { status in
|
||||
Text(status.displayName).tag(status as TaskStatus?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Cost")) {
|
||||
TextField("Estimated Cost (optional)", text: $estimatedCost)
|
||||
.keyboardType(.decimalPad)
|
||||
.focused($focusedField, equals: .estimatedCost)
|
||||
}
|
||||
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
Section {
|
||||
Text(errorMessage)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add Task")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
.navigationTitle("Add Task")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
submitForm()
|
||||
}
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
setDefaults()
|
||||
}
|
||||
.onChange(of: viewModel.taskCreated) { created in
|
||||
if created {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showCategoryPicker) {
|
||||
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 {
|
||||
setDefaults()
|
||||
}
|
||||
.onChange(of: viewModel.taskCreated) { created in
|
||||
if created {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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 errorMessage: String?
|
||||
@Published var taskCreated: Bool = false
|
||||
@Published var taskUpdated: Bool = false
|
||||
@Published var taskCancelled: Bool = false
|
||||
@Published var taskUncancelled: Bool = false
|
||||
|
||||
// MARK: - Private Properties
|
||||
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() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
func resetState() {
|
||||
taskCreated = false
|
||||
taskUpdated = false
|
||||
taskCancelled = false
|
||||
taskUncancelled = false
|
||||
errorMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user