From 1d48a9bff1ecc5f8ae6f5ce8c57d463aa6004fd6 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 5 Nov 2025 15:15:59 -0600 Subject: [PATCH] wip --- .../kotlin/com/example/mycrib/App.kt | 52 +++ .../com/example/mycrib/models/CustomTask.kt | 9 +- .../com/example/mycrib/navigation/Routes.kt | 20 + .../com/example/mycrib/network/TaskApi.kt | 32 ++ .../mycrib/ui/screens/EditTaskScreen.kt | 328 +++++++++++++++ .../ui/screens/ResidenceDetailScreen.kt | 155 ++++++- .../mycrib/viewmodel/ResidenceViewModel.kt | 57 +++ iosApp/iosApp/AddResidenceView.swift | 382 ++++++----------- iosApp/iosApp/EditResidenceView.swift | 326 ++++++--------- .../Residence/ResidenceDetailView.swift | 226 +++++----- iosApp/iosApp/Task/AddTaskView.swift | 391 +++++------------- iosApp/iosApp/Task/EditTaskView.swift | 163 ++++++++ iosApp/iosApp/Task/TaskViewModel.swift | 90 ++++ 13 files changed, 1360 insertions(+), 871 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditTaskScreen.kt create mode 100644 iosApp/iosApp/Task/EditTaskView.swift diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt index d2f9344..f5f5ffc 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt @@ -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 { backStackEntry -> + val route = backStackEntry.toRoute() + 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() } + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt index db62e20..21633e5 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt @@ -75,5 +75,12 @@ data class TaskDetail( data class TasksByResidenceResponse( @SerialName("residence_id") val residenceId: String, val summary: TaskSummary, - val tasks: List + val tasks: List, + @SerialName("cancelled_tasks") val cancelledTasks: List = emptyList() +) + +@Serializable +data class TaskCancelResponse( + val message: String, + val task: TaskDetail ) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt index dec5876..e0edc23 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt index 2667b4b..e15e393 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt @@ -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 { + 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 { + 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") + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditTaskScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditTaskScreen.kt new file mode 100644 index 0000000..515bb68 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditTaskScreen.kt @@ -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(task.category) } + var selectedFrequency by remember { mutableStateOf(task.frequency) } + var selectedPriority by remember { mutableStateOf(task.priority) } + var selectedStatus by remember { mutableStateOf(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)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt index 5d9f370..a4dd3b8 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt @@ -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(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") + } + } + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt index 1d2326c..645dde5 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt @@ -37,6 +37,15 @@ class ResidenceViewModel : ViewModel() { private val _myResidencesState = MutableStateFlow>(ApiResult.Loading) val myResidencesState: StateFlow> = _myResidencesState + private val _cancelTaskState = MutableStateFlow>(ApiResult.Loading) + val cancelTaskState: StateFlow> = _cancelTaskState + + private val _uncancelTaskState = MutableStateFlow>(ApiResult.Loading) + val uncancelTaskState: StateFlow> = _uncancelTaskState + + private val _updateTaskState = MutableStateFlow>(ApiResult.Loading) + val updateTaskState: StateFlow> = _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 + } } diff --git a/iosApp/iosApp/AddResidenceView.swift b/iosApp/iosApp/AddResidenceView.swift index 911d257..dce1337 100644 --- a/iosApp/iosApp/AddResidenceView.swift +++ b/iosApp/iosApp/AddResidenceView.swift @@ -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.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)) diff --git a/iosApp/iosApp/EditResidenceView.swift b/iosApp/iosApp/EditResidenceView.swift index 0ae77fb..7da4863 100644 --- a/iosApp/iosApp/EditResidenceView.swift +++ b/iosApp/iosApp/EditResidenceView.swift @@ -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 ?? "") - } } } diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift index f1cb389..1cb1576 100644 --- a/iosApp/iosApp/Residence/ResidenceDetailView.swift +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -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 { - 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 { + 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) diff --git a/iosApp/iosApp/Task/AddTaskView.swift b/iosApp/iosApp/Task/AddTaskView.swift index 175b188..7f96930 100644 --- a/iosApp/iosApp/Task/AddTaskView.swift +++ b/iosApp/iosApp/Task/AddTaskView.swift @@ -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)) diff --git a/iosApp/iosApp/Task/EditTaskView.swift b/iosApp/iosApp/Task/EditTaskView.swift new file mode 100644 index 0000000..e9c2b83 --- /dev/null +++ b/iosApp/iosApp/Task/EditTaskView.swift @@ -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) { + 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 + } + } + } +} diff --git a/iosApp/iosApp/Task/TaskViewModel.swift b/iosApp/iosApp/Task/TaskViewModel.swift index 781c57e..a96b02b 100644 --- a/iosApp/iosApp/Task/TaskViewModel.swift +++ b/iosApp/iosApp/Task/TaskViewModel.swift @@ -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 { + 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 { + 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 { + 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 } }