This commit is contained in:
Trey t
2025-11-05 15:15:59 -06:00
parent 5deac95818
commit 1d48a9bff1
13 changed files with 1360 additions and 871 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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