From b922c4fb88a917ddfd0fe5e677f5df7c5478ca9b Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 7 Nov 2025 14:53:14 -0600 Subject: [PATCH] wip --- .../com/example/mycrib/models/CustomTask.kt | 19 + .../com/example/mycrib/network/TaskApi.kt | 4 +- .../mycrib/ui/components/AddNewTaskDialog.kt | 1 + .../AddNewTaskWithResidenceDialog.kt | 1 + .../ui/components/task/TaskActionButtons.kt | 239 +++++++++ .../mycrib/ui/components/task/TaskCard.kt | 225 +++----- .../ui/components/task/TaskKanbanView.kt | 209 +++++++- .../mycrib/ui/screens/AllTasksScreen.kt | 14 +- .../mycrib/ui/screens/EditTaskScreen.kt | 1 + .../ui/screens/ResidenceDetailScreen.kt | 12 +- .../example/mycrib/ui/screens/TasksScreen.kt | 303 ++++++----- .../mycrib/viewmodel/ResidenceViewModel.kt | 6 +- .../example/mycrib/viewmodel/TaskViewModel.kt | 53 +- .../Residence/ResidenceDetailView.swift | 24 +- .../Subviews/Task/TaskActionButtons.swift | 185 +++++++ .../iosApp/Subviews/Task/TasksSection.swift | 237 ++++----- iosApp/iosApp/Task/AddTaskView.swift | 1 + .../Task/AddTaskWithResidenceView.swift | 1 + iosApp/iosApp/Task/AllTasksView.swift | 479 +++++++++++------- iosApp/iosApp/Task/EditTaskView.swift | 1 + 20 files changed, 1366 insertions(+), 649 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskActionButtons.kt create mode 100644 iosApp/iosApp/Subviews/Task/TaskActionButtons.swift 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 016b8fe..f763715 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt @@ -44,6 +44,7 @@ data class TaskCreateRequest( val frequency: Int, @SerialName("interval_days") val intervalDays: Int? = null, val priority: Int, + val status: Int? = null, @SerialName("due_date") val dueDate: String, @SerialName("estimated_cost") val estimatedCost: String? = null ) @@ -106,3 +107,21 @@ data class TaskCancelResponse( val message: String, val task: TaskDetail ) + +@Serializable +data class TaskColumn( + val name: String, + @SerialName("display_name") val displayName: String, + @SerialName("button_types") val buttonTypes: List, + val icons: Map, + val color: String, + val tasks: List, + val count: Int +) + +@Serializable +data class TaskColumnsResponse( + val columns: List, + @SerialName("days_threshold") val daysThreshold: Int? = null, + @SerialName("residence_id") val residenceId: String? = null +) 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 1d749a8..4ac5e53 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt @@ -12,7 +12,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getTasks( token: String, days: Int = 30 - ): ApiResult { + ): ApiResult { return try { val response = client.get("$baseUrl/tasks/") { header("Authorization", "Token $token") @@ -101,7 +101,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { token: String, residenceId: Int, days: Int = 30 - ): ApiResult { + ): ApiResult { return try { val response = client.get("$baseUrl/tasks/by-residence/$residenceId/") { header("Authorization", "Token $token") diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt index a749adb..c05adb1 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt @@ -273,6 +273,7 @@ fun AddNewTaskDialog( frequency = frequency.id, intervalDays = intervalDays.toIntOrNull(), priority = priority.id, + status = null, dueDate = dueDate, estimatedCost = estimatedCost.ifBlank { null } ) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskWithResidenceDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskWithResidenceDialog.kt index eded1da..2a12197 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskWithResidenceDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskWithResidenceDialog.kt @@ -330,6 +330,7 @@ fun AddNewTaskWithResidenceDialog( frequency = frequency.id, intervalDays = intervalDays.toIntOrNull(), priority = priority.id, + status = null, dueDate = dueDate, estimatedCost = estimatedCost.ifBlank { null } ) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskActionButtons.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskActionButtons.kt new file mode 100644 index 0000000..6d017be --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskActionButtons.kt @@ -0,0 +1,239 @@ +package com.mycrib.android.ui.components.task + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mycrib.android.viewmodel.TaskViewModel + +// MARK: - Edit Task Button +@Composable +fun EditTaskButton( + taskId: Int, + onCompletion: () -> Unit, + onError: (String) -> Unit, + modifier: Modifier = Modifier +) { + Button( + onClick = { + // Edit navigates to edit screen - handled by parent + onCompletion() + }, + modifier = modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Edit", style = MaterialTheme.typography.labelLarge) + } +} + +// MARK: - Cancel Task Button +@Composable +fun CancelTaskButton( + taskId: Int, + onCompletion: () -> Unit, + onError: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: TaskViewModel = viewModel { TaskViewModel() } +) { + OutlinedButton( + onClick = { + viewModel.cancelTask(taskId) { success -> + if (success) { + onCompletion() + } else { + onError("Failed to cancel task") + } + } + }, + modifier = modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Icon( + imageVector = Icons.Default.Cancel, + contentDescription = "Cancel", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Cancel", style = MaterialTheme.typography.labelLarge) + } +} + +// MARK: - Uncancel (Restore) Task Button +@Composable +fun UncancelTaskButton( + taskId: Int, + onCompletion: () -> Unit, + onError: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: TaskViewModel = viewModel { TaskViewModel() } +) { + Button( + onClick = { + viewModel.uncancelTask(taskId) { success -> + if (success) { + onCompletion() + } else { + onError("Failed to restore task") + } + } + }, + modifier = modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + imageVector = Icons.Default.Undo, + contentDescription = "Restore", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Restore", style = MaterialTheme.typography.labelLarge) + } +} + +// MARK: - Mark In Progress Button +@Composable +fun MarkInProgressButton( + taskId: Int, + onCompletion: () -> Unit, + onError: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: TaskViewModel = viewModel { TaskViewModel() } +) { + OutlinedButton( + onClick = { + viewModel.markInProgress(taskId) { success -> + if (success) { + onCompletion() + } else { + onError("Failed to mark task in progress") + } + } + }, + modifier = modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.tertiary + ) + ) { + Icon( + imageVector = Icons.Default.PlayCircle, + contentDescription = "Mark In Progress", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("In Progress", style = MaterialTheme.typography.labelLarge) + } +} + +// MARK: - Complete Task Button +@Composable +fun CompleteTaskButton( + taskId: Int, + onCompletion: () -> Unit, + onError: (String) -> Unit, + modifier: Modifier = Modifier +) { + Button( + onClick = { + // Complete shows dialog - handled by parent + onCompletion() + }, + modifier = modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Complete", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Complete", style = MaterialTheme.typography.labelLarge) + } +} + +// MARK: - Archive Task Button +@Composable +fun ArchiveTaskButton( + taskId: Int, + onCompletion: () -> Unit, + onError: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: TaskViewModel = viewModel { TaskViewModel() } +) { + OutlinedButton( + onClick = { + viewModel.archiveTask(taskId) { success -> + if (success) { + onCompletion() + } else { + onError("Failed to archive task") + } + } + }, + modifier = modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.outline + ) + ) { + Icon( + imageVector = Icons.Default.Archive, + contentDescription = "Archive", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Archive", style = MaterialTheme.typography.labelLarge) + } +} + +// MARK: - Unarchive Task Button +@Composable +fun UnarchiveTaskButton( + taskId: Int, + onCompletion: () -> Unit, + onError: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: TaskViewModel = viewModel { TaskViewModel() } +) { + Button( + onClick = { + viewModel.unarchiveTask(taskId) { success -> + if (success) { + onCompletion() + } else { + onError("Failed to unarchive task") + } + } + }, + modifier = modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Icon( + imageVector = Icons.Default.Unarchive, + contentDescription = "Unarchive", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Unarchive", style = MaterialTheme.typography.labelLarge) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt index 1b3d5f0..4e17444 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt @@ -20,6 +20,7 @@ import org.jetbrains.compose.ui.tooling.preview.Preview @Composable fun TaskCard( task: TaskDetail, + buttonTypes: List = emptyList(), onCompleteClick: (() -> Unit)?, onEditClick: () -> Unit, onCancelClick: (() -> Unit)?, @@ -243,161 +244,87 @@ fun TaskCard( } } - // Show complete task button and mark in progress button - if ((task.showCompletedButton && onCompleteClick != null) || (onMarkInProgressClick != null && task.status?.name != "in_progress")) { + // Render buttons based on buttonTypes array + if (buttonTypes.isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - // Mark In Progress button - if (onMarkInProgressClick != null && task.status?.name != "in_progress") { - Button( - onClick = onMarkInProgressClick, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.tertiary - ) - ) { - Icon( - Icons.Default.PlayArrow, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - "In Progress", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold - ) - } - } - // Complete Task button - if (task.showCompletedButton && onCompleteClick != null) { - Button( - onClick = onCompleteClick, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp) - ) { - Icon( - Icons.Default.CheckCircle, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - "Complete", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold - ) + buttonTypes.forEach { buttonType -> + when (buttonType) { + "mark_in_progress" -> { + onMarkInProgressClick?.let { + MarkInProgressButton( + taskId = task.id, + onCompletion = it, + onError = { error -> println("Error: $error") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } } - } - } - } - - // 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") + "complete" -> { + onCompleteClick?.let { + CompleteTaskButton( + taskId = task.id, + onCompletion = it, + onError = { error -> println("Error: $error") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } } - } - onUncancelClick != null -> { - Button( - onClick = onUncancelClick, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary + "edit" -> { + EditTaskButton( + taskId = task.id, + onCompletion = onEditClick, + onError = { error -> println("Error: $error") }, + modifier = Modifier.fillMaxWidth() ) - ) { - Icon( - Icons.Default.Undo, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text("Restore") + Spacer(modifier = Modifier.height(8.dp)) + } + "cancel" -> { + onCancelClick?.let { + CancelTaskButton( + taskId = task.id, + onCompletion = it, + onError = { error -> println("Error: $error") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + "uncancel" -> { + onUncancelClick?.let { + UncancelTaskButton( + taskId = task.id, + onCompletion = it, + onError = { error -> println("Error: $error") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + "archive" -> { + onArchiveClick?.let { + ArchiveTaskButton( + taskId = task.id, + onCompletion = it, + onError = { error -> println("Error: $error") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + "unarchive" -> { + onUnarchiveClick?.let { + UnarchiveTaskButton( + taskId = task.id, + onCompletion = it, + onError = { error -> println("Error: $error") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } } - } - } - } - - // Archive/Unarchive button row - if (task.archived) { - if (onUnarchiveClick != null) { - Spacer(modifier = Modifier.height(8.dp)) - OutlinedButton( - onClick = onUnarchiveClick, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.primary - ) - ) { - Icon( - Icons.Default.Unarchive, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text("Unarchive") - } - } - } else { - if (onArchiveClick != null) { - Spacer(modifier = Modifier.height(8.dp)) - OutlinedButton( - onClick = onArchiveClick, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.outline - ) - ) { - Icon( - Icons.Default.Archive, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text("Archive") } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskKanbanView.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskKanbanView.kt index f67e407..961cc29 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskKanbanView.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskKanbanView.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.mycrib.shared.models.AllTasksResponse +import com.mycrib.shared.models.TaskColumn import com.mycrib.shared.models.TaskDetail @OptIn(ExperimentalFoundationApi::class) @@ -227,3 +227,210 @@ private fun TaskColumn( } } } + +/** + * Dynamic Task Kanban View that creates columns based on API response + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun DynamicTaskKanbanView( + columns: List, + onCompleteTask: (TaskDetail) -> Unit, + onEditTask: (TaskDetail) -> Unit, + onCancelTask: ((TaskDetail) -> Unit)?, + onUncancelTask: ((TaskDetail) -> Unit)?, + onMarkInProgress: ((TaskDetail) -> Unit)?, + onArchiveTask: ((TaskDetail) -> Unit)?, + onUnarchiveTask: ((TaskDetail) -> Unit)? +) { + val pagerState = rememberPagerState(pageCount = { columns.size }) + + Column(modifier = Modifier.fillMaxSize()) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + pageSpacing = 16.dp, + contentPadding = PaddingValues(horizontal = 16.dp) + ) { page -> + val column = columns[page] + DynamicTaskColumn( + column = column, + onCompleteTask = onCompleteTask, + onEditTask = onEditTask, + onCancelTask = onCancelTask, + onUncancelTask = onUncancelTask, + onMarkInProgress = onMarkInProgress, + onArchiveTask = onArchiveTask, + onUnarchiveTask = onUnarchiveTask + ) + } + } +} + +/** + * Dynamic Task Column that adapts based on column configuration + */ +@Composable +private fun DynamicTaskColumn( + column: TaskColumn, + onCompleteTask: (TaskDetail) -> Unit, + onEditTask: (TaskDetail) -> Unit, + onCancelTask: ((TaskDetail) -> Unit)?, + onUncancelTask: ((TaskDetail) -> Unit)?, + onMarkInProgress: ((TaskDetail) -> Unit)?, + onArchiveTask: ((TaskDetail) -> Unit)?, + onUnarchiveTask: ((TaskDetail) -> Unit)? +) { + // Get icon from API response, with fallback + val columnIcon = getIconFromName(column.icons["android"] ?: "List") + + val columnColor = hexToColor(column.color) + + Column( + modifier = Modifier + .fillMaxSize() + .background( + MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(12.dp) + ) + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = columnIcon, + contentDescription = null, + tint = columnColor, + modifier = Modifier.size(24.dp) + ) + Text( + text = column.displayName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = columnColor + ) + } + + Surface( + color = columnColor, + shape = CircleShape + ) { + Text( + text = column.count.toString(), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.surface + ) + } + } + + // Tasks List + if (column.tasks.isEmpty()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = columnIcon, + contentDescription = null, + tint = columnColor.copy(alpha = 0.3f), + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "No tasks", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(column.tasks, key = { it.id }) { task -> + // Use existing TaskCard component with buttonTypes array + TaskCard( + task = task, + buttonTypes = column.buttonTypes, + onCompleteClick = { onCompleteTask(task) }, + onEditClick = { onEditTask(task) }, + onCancelClick = onCancelTask?.let { { it(task) } }, + onUncancelClick = onUncancelTask?.let { { it(task) } }, + onMarkInProgressClick = onMarkInProgress?.let { { it(task) } }, + onArchiveClick = onArchiveTask?.let { { it(task) } }, + onUnarchiveClick = onUnarchiveTask?.let { { it(task) } } + ) + } + } + } + } +} + +/** + * Helper function to convert icon name string to ImageVector + */ +private fun getIconFromName(iconName: String): ImageVector { + return when (iconName) { + "CalendarToday" -> Icons.Default.CalendarToday + "PlayCircle" -> Icons.Default.PlayCircle + "CheckCircle" -> Icons.Default.CheckCircle + "Archive" -> Icons.Default.Archive + "List" -> Icons.Default.List + "PlayArrow" -> Icons.Default.PlayArrow + "Unarchive" -> Icons.Default.Unarchive + else -> Icons.Default.List // Default fallback + } +} + +/** + * Helper function to convert hex color string to Color + * Supports formats: #RGB, #RRGGBB, #AARRGGBB + * Platform-independent implementation + */ +private fun hexToColor(hex: String): Color { + val cleanHex = hex.removePrefix("#") + return try { + when (cleanHex.length) { + 3 -> { + // RGB format - expand to RRGGBB + val r = cleanHex[0].toString().repeat(2).toInt(16) + val g = cleanHex[1].toString().repeat(2).toInt(16) + val b = cleanHex[2].toString().repeat(2).toInt(16) + Color(red = r / 255f, green = g / 255f, blue = b / 255f) + } + 6 -> { + // RRGGBB format + val r = cleanHex.substring(0, 2).toInt(16) + val g = cleanHex.substring(2, 4).toInt(16) + val b = cleanHex.substring(4, 6).toInt(16) + Color(red = r / 255f, green = g / 255f, blue = b / 255f) + } + 8 -> { + // AARRGGBB format + val a = cleanHex.substring(0, 2).toInt(16) + val r = cleanHex.substring(2, 4).toInt(16) + val g = cleanHex.substring(4, 6).toInt(16) + val b = cleanHex.substring(6, 8).toInt(16) + Color(red = r / 255f, green = g / 255f, blue = b / 255f, alpha = a / 255f) + } + else -> Color.Gray // Default fallback + } + } catch (e: Exception) { + Color.Gray // Fallback on parse error + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AllTasksScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AllTasksScreen.kt index 489be49..3acce58 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AllTasksScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AllTasksScreen.kt @@ -15,7 +15,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.mycrib.android.ui.components.AddNewTaskWithResidenceDialog import com.mycrib.android.ui.components.CompleteTaskDialog import com.mycrib.android.ui.components.task.TaskCard -import com.mycrib.android.ui.components.task.TaskKanbanView +import com.mycrib.android.ui.components.task.DynamicTaskKanbanView import com.mycrib.android.viewmodel.ResidenceViewModel import com.mycrib.android.viewmodel.TaskCompletionViewModel import com.mycrib.android.viewmodel.TaskViewModel @@ -149,10 +149,7 @@ fun AllTasksScreen( } is ApiResult.Success -> { val taskData = (tasksState as ApiResult.Success).data - val hasNoTasks = taskData.upcomingTasks.isEmpty() && - taskData.inProgressTasks.isEmpty() && - taskData.doneTasks.isEmpty() && - taskData.archivedTasks.isEmpty() + val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() } if (hasNoTasks) { Box( @@ -219,11 +216,8 @@ fun AllTasksScreen( .fillMaxSize() .padding(paddingValues) ) { - TaskKanbanView( - upcomingTasks = taskData.upcomingTasks, - inProgressTasks = taskData.inProgressTasks, - doneTasks = taskData.doneTasks, - archivedTasks = taskData.archivedTasks, + DynamicTaskKanbanView( + columns = taskData.columns, onCompleteTask = { task -> selectedTask = task showCompleteDialog = true 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 index 9e1205f..515bb68 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditTaskScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditTaskScreen.kt @@ -300,6 +300,7 @@ fun EditTaskScreen( category = selectedCategory!!.id, frequency = selectedFrequency!!.id, priority = selectedPriority!!.id, + status = selectedStatus!!.id, dueDate = dueDate, estimatedCost = estimatedCost.ifBlank { null } ) 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 e653234..05b9572 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 @@ -20,7 +20,7 @@ import com.mycrib.android.ui.components.common.InfoCard import com.mycrib.android.ui.components.residence.PropertyDetailItem import com.mycrib.android.ui.components.residence.DetailRow import com.mycrib.android.ui.components.task.TaskCard -import com.mycrib.android.ui.components.task.TaskKanbanView +import com.mycrib.android.ui.components.task.DynamicTaskKanbanView import com.mycrib.android.viewmodel.ResidenceViewModel import com.mycrib.android.viewmodel.TaskCompletionViewModel import com.mycrib.android.viewmodel.TaskViewModel @@ -393,7 +393,8 @@ fun ResidenceDetailScreen( } is ApiResult.Success -> { val taskData = (tasksState as ApiResult.Success).data - if (taskData.upcomingTasks.isEmpty() && taskData.inProgressTasks.isEmpty() && taskData.doneTasks.isEmpty() && taskData.archivedTasks.isEmpty()) { + val allTasksEmpty = taskData.columns.all { it.tasks.isEmpty() } + if (allTasksEmpty) { item { Card( modifier = Modifier.fillMaxWidth(), @@ -432,11 +433,8 @@ fun ResidenceDetailScreen( .fillMaxWidth() .height(500.dp) ) { - TaskKanbanView( - upcomingTasks = taskData.upcomingTasks, - inProgressTasks = taskData.inProgressTasks, - doneTasks = taskData.doneTasks, - archivedTasks = taskData.archivedTasks, + DynamicTaskKanbanView( + columns = taskData.columns, onCompleteTask = { task -> selectedTask = task showCompleteDialog = true diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt index 69bdd59..31538ce 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt @@ -26,8 +26,7 @@ fun TasksScreen( ) { val tasksState by viewModel.tasksState.collectAsState() val completionState by taskCompletionViewModel.createCompletionState.collectAsState() - var showInProgressTasks by remember { mutableStateOf(false) } - var showDoneTasks by remember { mutableStateOf(false) } + var expandedColumns by remember { mutableStateOf(setOf()) } var showCompleteDialog by remember { mutableStateOf(false) } var selectedTask by remember { mutableStateOf(null) } @@ -93,9 +92,7 @@ fun TasksScreen( } is ApiResult.Success -> { val taskData = (tasksState as ApiResult.Success).data - val hasNoTasks = taskData.upcomingTasks.isEmpty() && - taskData.inProgressTasks.isEmpty() && - taskData.doneTasks.isEmpty() + val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() } if (hasNoTasks) { Box( @@ -140,152 +137,106 @@ fun TasksScreen( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // Task summary pills + // Task summary pills - dynamically generated from all columns item { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - TaskPill( - count = taskData.summary.upcoming, - label = "Upcoming", - color = MaterialTheme.colorScheme.primary - ) - TaskPill( - count = taskData.summary.inProgress, - label = "In Progress", - color = MaterialTheme.colorScheme.tertiary - ) - TaskPill( - count = taskData.summary.done, - label = "Done", - color = MaterialTheme.colorScheme.secondary - ) - } - } - - // Upcoming tasks header - if (taskData.upcomingTasks.isNotEmpty()) { - item { - Text( - text = "Upcoming (${taskData.upcomingTasks.size})", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(top = 8.dp) - ) - } - } - - // Upcoming tasks - items(taskData.upcomingTasks) { task -> - TaskCard( - task = task, - onCompleteClick = { - selectedTask = task - showCompleteDialog = true - }, - onEditClick = { }, - onCancelClick = { }, - onUncancelClick = { } - ) - } - - // In Progress section (collapsible) - if (taskData.inProgressTasks.isNotEmpty()) { - item { - Card( - modifier = Modifier.fillMaxWidth(), - onClick = { showInProgressTasks = !showInProgressTasks } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically - ) { - Icon( - Icons.Default.PlayArrow, - contentDescription = null, - tint = MaterialTheme.colorScheme.tertiary - ) - Text( - text = "In Progress (${taskData.inProgressTasks.size})", - style = MaterialTheme.typography.titleMedium - ) - } - Icon( - if (showInProgressTasks) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, - contentDescription = if (showInProgressTasks) "Collapse" else "Expand" - ) - } - } - } - - if (showInProgressTasks) { - items(taskData.inProgressTasks) { task -> - TaskCard( - task = task, - onCompleteClick = { - selectedTask = task - showCompleteDialog = true - }, - onEditClick = { /* TODO */ }, - onCancelClick = {}, - onUncancelClick = {} + taskData.columns.forEach { column -> + TaskPill( + count = column.count, + label = column.displayName, + color = hexToColor(column.color) ) } } } - // Done section (collapsible) - if (taskData.doneTasks.isNotEmpty()) { - item { - Card( - modifier = Modifier.fillMaxWidth(), - onClick = { showDoneTasks = !showDoneTasks } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically - ) { - Icon( - Icons.Default.CheckCircle, - contentDescription = null, - tint = MaterialTheme.colorScheme.secondary - ) - Text( - text = "Done (${taskData.doneTasks.size})", - style = MaterialTheme.typography.titleMedium - ) - } - Icon( - if (showDoneTasks) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, - contentDescription = if (showDoneTasks) "Collapse" else "Expand" + // Dynamically render all columns + taskData.columns.forEachIndexed { index, column -> + if (column.tasks.isNotEmpty()) { + // First column (index 0) expanded by default, others collapsible + if (index == 0) { + // First column - always expanded, show tasks directly + item { + Text( + text = "${column.displayName} (${column.tasks.size})", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp) ) } - } - } - if (showDoneTasks) { - items(taskData.doneTasks) { task -> - TaskCard( - task = task, - onCompleteClick = { /* TODO */ }, - onEditClick = { /* TODO */ }, - onUncancelClick = {}, - onCancelClick = {} - ) + items(column.tasks) { task -> + TaskCard( + task = task, + onCompleteClick = { + selectedTask = task + showCompleteDialog = true + }, + onEditClick = { }, + onCancelClick = { }, + onUncancelClick = { } + ) + } + } else { + // Other columns - collapsible + val isExpanded = expandedColumns.contains(column.name) + + item { + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { + expandedColumns = if (isExpanded) { + expandedColumns - column.name + } else { + expandedColumns + column.name + } + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Icon( + getIconFromName(column.icons["android"] ?: "List"), + contentDescription = null, + tint = hexToColor(column.color) + ) + Text( + text = "${column.displayName} (${column.tasks.size})", + style = MaterialTheme.typography.titleMedium + ) + } + Icon( + if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (isExpanded) "Collapse" else "Expand" + ) + } + } + } + + if (isExpanded) { + items(column.tasks) { task -> + TaskCard( + task = task, + onCompleteClick = { + selectedTask = task + showCompleteDialog = true + }, + onEditClick = { }, + onCancelClick = { }, + onUncancelClick = { } + ) + } + } } } } @@ -349,3 +300,79 @@ private fun TaskPill( } } } + +@Composable +private fun getColumnColor(columnName: String): androidx.compose.ui.graphics.Color { + return when (columnName) { + "upcoming_tasks" -> MaterialTheme.colorScheme.primary + "in_progress_tasks" -> MaterialTheme.colorScheme.tertiary + "done_tasks" -> MaterialTheme.colorScheme.secondary + "archived_tasks" -> MaterialTheme.colorScheme.outline + else -> MaterialTheme.colorScheme.primary // Default color for unknown columns + } +} + +@Composable +private fun getColumnIcon(columnName: String): androidx.compose.ui.graphics.vector.ImageVector { + return when (columnName) { + "upcoming_tasks" -> Icons.Default.CalendarToday + "in_progress_tasks" -> Icons.Default.PlayArrow + "done_tasks" -> Icons.Default.CheckCircle + "archived_tasks" -> Icons.Default.Archive + else -> Icons.Default.List // Default icon for unknown columns + } +} + +/** + * Helper function to convert icon name string to ImageVector + */ +private fun getIconFromName(iconName: String): androidx.compose.ui.graphics.vector.ImageVector { + return when (iconName) { + "CalendarToday" -> Icons.Default.CalendarToday + "PlayCircle" -> Icons.Default.PlayCircle + "PlayArrow" -> Icons.Default.PlayArrow + "CheckCircle" -> Icons.Default.CheckCircle + "Archive" -> Icons.Default.Archive + "List" -> Icons.Default.List + "Unarchive" -> Icons.Default.Unarchive + else -> Icons.Default.List // Default fallback + } +} + +/** + * Helper function to convert hex color string to Color + * Supports formats: #RGB, #RRGGBB, #AARRGGBB + * Platform-independent implementation + */ +private fun hexToColor(hex: String): androidx.compose.ui.graphics.Color { + val cleanHex = hex.removePrefix("#") + return try { + when (cleanHex.length) { + 3 -> { + // RGB format - expand to RRGGBB + val r = cleanHex[0].toString().repeat(2).toInt(16) + val g = cleanHex[1].toString().repeat(2).toInt(16) + val b = cleanHex[2].toString().repeat(2).toInt(16) + androidx.compose.ui.graphics.Color(red = r / 255f, green = g / 255f, blue = b / 255f) + } + 6 -> { + // RRGGBB format + val r = cleanHex.substring(0, 2).toInt(16) + val g = cleanHex.substring(2, 4).toInt(16) + val b = cleanHex.substring(4, 6).toInt(16) + androidx.compose.ui.graphics.Color(red = r / 255f, green = g / 255f, blue = b / 255f) + } + 8 -> { + // AARRGGBB format + val a = cleanHex.substring(0, 2).toInt(16) + val r = cleanHex.substring(2, 4).toInt(16) + val g = cleanHex.substring(4, 6).toInt(16) + val b = cleanHex.substring(6, 8).toInt(16) + androidx.compose.ui.graphics.Color(red = r / 255f, green = g / 255f, blue = b / 255f, alpha = a / 255f) + } + else -> androidx.compose.ui.graphics.Color.Gray // Default fallback + } + } catch (e: Exception) { + androidx.compose.ui.graphics.Color.Gray // Fallback on parse error + } +} 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 933536d..2fc5e94 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt @@ -6,7 +6,7 @@ import com.mycrib.shared.models.Residence import com.mycrib.shared.models.ResidenceCreateRequest import com.mycrib.shared.models.ResidenceSummaryResponse import com.mycrib.shared.models.MyResidencesResponse -import com.mycrib.shared.models.TasksByResidenceResponse +import com.mycrib.shared.models.TaskColumnsResponse import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.ResidenceApi import com.mycrib.shared.network.TaskApi @@ -31,8 +31,8 @@ class ResidenceViewModel : ViewModel() { private val _updateResidenceState = MutableStateFlow>(ApiResult.Idle) val updateResidenceState: StateFlow> = _updateResidenceState - private val _residenceTasksState = MutableStateFlow>(ApiResult.Idle) - val residenceTasksState: StateFlow> = _residenceTasksState + private val _residenceTasksState = MutableStateFlow>(ApiResult.Idle) + val residenceTasksState: StateFlow> = _residenceTasksState private val _myResidencesState = MutableStateFlow>(ApiResult.Idle) val myResidencesState: StateFlow> = _myResidencesState diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt index 4396e40..281fa8a 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt @@ -2,10 +2,9 @@ package com.mycrib.android.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.mycrib.shared.models.AllTasksResponse +import com.mycrib.shared.models.TaskColumnsResponse import com.mycrib.shared.models.CustomTask import com.mycrib.shared.models.TaskCreateRequest -import com.mycrib.shared.models.TasksByResidenceResponse import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.TaskApi import com.mycrib.storage.TokenStorage @@ -16,11 +15,11 @@ import kotlinx.coroutines.launch class TaskViewModel : ViewModel() { private val taskApi = TaskApi() - private val _tasksState = MutableStateFlow>(ApiResult.Idle) - val tasksState: StateFlow> = _tasksState + private val _tasksState = MutableStateFlow>(ApiResult.Idle) + val tasksState: StateFlow> = _tasksState - private val _tasksByResidenceState = MutableStateFlow>(ApiResult.Idle) - val tasksByResidenceState: StateFlow> = _tasksByResidenceState + private val _tasksByResidenceState = MutableStateFlow>(ApiResult.Idle) + val tasksByResidenceState: StateFlow> = _tasksByResidenceState private val _taskAddNewCustomTaskState = MutableStateFlow>(ApiResult.Idle) val taskAddNewCustomTaskState: StateFlow> = _taskAddNewCustomTaskState @@ -74,6 +73,48 @@ class TaskViewModel : ViewModel() { _taskAddNewCustomTaskState.value = ApiResult.Idle } + fun cancelTask(taskId: Int, onComplete: (Boolean) -> Unit) { + viewModelScope.launch { + val token = TokenStorage.getToken() + if (token != null) { + when (val result = taskApi.cancelTask(token, taskId)) { + is ApiResult.Success -> { + onComplete(true) + } + is ApiResult.Error -> { + onComplete(false) + } + else -> { + onComplete(false) + } + } + } else { + onComplete(false) + } + } + } + + fun uncancelTask(taskId: Int, onComplete: (Boolean) -> Unit) { + viewModelScope.launch { + val token = TokenStorage.getToken() + if (token != null) { + when (val result = taskApi.uncancelTask(token, taskId)) { + is ApiResult.Success -> { + onComplete(true) + } + is ApiResult.Error -> { + onComplete(false) + } + else -> { + onComplete(false) + } + } + } else { + onComplete(false) + } + } + } + fun markInProgress(taskId: Int, onComplete: (Boolean) -> Unit) { viewModelScope.launch { val token = TokenStorage.getToken() diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift index 700b215..ba118d9 100644 --- a/iosApp/iosApp/Residence/ResidenceDetailView.swift +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -5,7 +5,7 @@ struct ResidenceDetailView: View { let residenceId: Int32 @StateObject private var viewModel = ResidenceViewModel() @StateObject private var taskViewModel = TaskViewModel() - @State private var tasksResponse: TasksByResidenceResponse? + @State private var tasksResponse: TaskColumnsResponse? @State private var isLoadingTasks = false @State private var tasksError: String? @State private var showAddTask = false @@ -41,18 +41,18 @@ struct ResidenceDetailView: View { selectedTaskForEdit = task showEditTask = true }, - onCancelTask: { task in - taskViewModel.cancelTask(id: task.id) { _ in + onCancelTask: { taskId in + taskViewModel.cancelTask(id: taskId) { _ in loadResidenceTasks() } }, - onUncancelTask: { task in - taskViewModel.uncancelTask(id: task.id) { _ in + onUncancelTask: { taskId in + taskViewModel.uncancelTask(id: taskId) { _ in loadResidenceTasks() } }, - onMarkInProgress: { task in - taskViewModel.markInProgress(id: task.id) { success in + onMarkInProgress: { taskId in + taskViewModel.markInProgress(id: taskId) { success in if success { loadResidenceTasks() } @@ -61,13 +61,13 @@ struct ResidenceDetailView: View { onCompleteTask: { task in selectedTaskForComplete = task }, - onArchiveTask: { task in - taskViewModel.archiveTask(id: task.id) { _ in + onArchiveTask: { taskId in + taskViewModel.archiveTask(id: taskId) { _ in loadResidenceTasks() } }, - onUnarchiveTask: { task in - taskViewModel.unarchiveTask(id: task.id) { _ in + onUnarchiveTask: { taskId in + taskViewModel.unarchiveTask(id: taskId) { _ in loadResidenceTasks() } } @@ -158,7 +158,7 @@ struct ResidenceDetailView: View { let taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient()) taskApi.getTasksByResidence(token: token, residenceId: residenceId, days: 30) { result, error in - if let successResult = result as? ApiResultSuccess { + if let successResult = result as? ApiResultSuccess { self.tasksResponse = successResult.data self.isLoadingTasks = false } else if let errorResult = result as? ApiResultError { diff --git a/iosApp/iosApp/Subviews/Task/TaskActionButtons.swift b/iosApp/iosApp/Subviews/Task/TaskActionButtons.swift new file mode 100644 index 0000000..d9fcc2d --- /dev/null +++ b/iosApp/iosApp/Subviews/Task/TaskActionButtons.swift @@ -0,0 +1,185 @@ +import SwiftUI +import ComposeApp + +// MARK: - Edit Task Button +struct EditTaskButton: View { + let taskId: Int32 + let onCompletion: () -> Void + let onError: (String) -> Void + + var body: some View { + Button(action: { + // Edit navigates to edit screen - handled by parent + onCompletion() + }) { + Label("Edit", systemImage: "pencil") + .font(.subheadline) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } +} + +// MARK: - Cancel Task Button +struct CancelTaskButton: View { + let taskId: Int32 + let onCompletion: () -> Void + let onError: (String) -> Void + + @StateObject private var viewModel = TaskViewModel() + + var body: some View { + Button(action: { + viewModel.cancelTask(id: taskId) { success in + if success { + onCompletion() + } else { + onError("Failed to cancel task") + } + } + }) { + Label("Cancel", systemImage: "xmark.circle") + .font(.subheadline) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.red) + } +} + +// MARK: - Uncancel (Restore) Task Button +struct UncancelTaskButton: View { + let taskId: Int32 + let onCompletion: () -> Void + let onError: (String) -> Void + + @StateObject private var viewModel = TaskViewModel() + + var body: some View { + Button(action: { + viewModel.uncancelTask(id: taskId) { success in + if success { + onCompletion() + } else { + onError("Failed to restore task") + } + } + }) { + Label("Restore", systemImage: "arrow.uturn.backward") + .font(.subheadline) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + } +} + +// MARK: - Mark In Progress Button +struct MarkInProgressButton: View { + let taskId: Int32 + let onCompletion: () -> Void + let onError: (String) -> Void + + @StateObject private var viewModel = TaskViewModel() + + var body: some View { + Button(action: { + viewModel.markInProgress(id: taskId) { success in + if success { + onCompletion() + } else { + onError("Failed to mark task in progress") + } + } + }) { + HStack { + Image(systemName: "play.circle.fill") + .resizable() + .frame(width: 18, height: 18) + Text("In Progress") + .font(.subheadline.weight(.semibold)) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.orange) + } +} + +// MARK: - Complete Task Button +struct CompleteTaskButton: View { + let taskId: Int32 + let onCompletion: () -> Void + let onError: (String) -> Void + + var body: some View { + Button(action: { + // Complete shows dialog - handled by parent + onCompletion() + }) { + HStack { + Image(systemName: "checkmark.circle.fill") + .resizable() + .frame(width: 18, height: 18) + Text("Complete") + .font(.subheadline.weight(.semibold)) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } +} + +// MARK: - Archive Task Button +struct ArchiveTaskButton: View { + let taskId: Int32 + let onCompletion: () -> Void + let onError: (String) -> Void + + @StateObject private var viewModel = TaskViewModel() + + var body: some View { + Button(action: { + viewModel.archiveTask(id: taskId) { success in + if success { + onCompletion() + } else { + onError("Failed to archive task") + } + } + }) { + Label("Archive", systemImage: "archivebox") + .font(.subheadline) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.gray) + } +} + +// MARK: - Unarchive Task Button +struct UnarchiveTaskButton: View { + let taskId: Int32 + let onCompletion: () -> Void + let onError: (String) -> Void + + @StateObject private var viewModel = TaskViewModel() + + var body: some View { + Button(action: { + viewModel.unarchiveTask(id: taskId) { success in + if success { + onCompletion() + } else { + onError("Failed to unarchive task") + } + } + }) { + Label("Unarchive", systemImage: "tray.and.arrow.up") + .font(.subheadline) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.blue) + } +} diff --git a/iosApp/iosApp/Subviews/Task/TasksSection.swift b/iosApp/iosApp/Subviews/Task/TasksSection.swift index 8ce9ca5..9ae0f1b 100644 --- a/iosApp/iosApp/Subviews/Task/TasksSection.swift +++ b/iosApp/iosApp/Subviews/Task/TasksSection.swift @@ -2,14 +2,18 @@ import SwiftUI import ComposeApp struct TasksSection: View { - let tasksResponse: TasksByResidenceResponse + let tasksResponse: TaskColumnsResponse let onEditTask: (TaskDetail) -> Void - let onCancelTask: (TaskDetail) -> Void - let onUncancelTask: (TaskDetail) -> Void - let onMarkInProgress: (TaskDetail) -> Void + let onCancelTask: (Int32) -> Void + let onUncancelTask: (Int32) -> Void + let onMarkInProgress: (Int32) -> Void let onCompleteTask: (TaskDetail) -> Void - let onArchiveTask: (TaskDetail) -> Void - let onUnarchiveTask: (TaskDetail) -> Void + let onArchiveTask: (Int32) -> Void + let onUnarchiveTask: (Int32) -> Void + + private var hasNoTasks: Bool { + tasksResponse.columns.allSatisfy { $0.tasks.isEmpty } + } var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -17,79 +21,40 @@ struct TasksSection: View { .font(.title2) .fontWeight(.bold) - if tasksResponse.upcomingTasks.isEmpty && tasksResponse.inProgressTasks.isEmpty && tasksResponse.doneTasks.isEmpty && tasksResponse.archivedTasks.isEmpty { + if hasNoTasks { EmptyTasksView() } else { GeometryReader { geometry in ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: 16) { - // Upcoming Column - TaskColumnView( - title: "Upcoming", - icon: "calendar", - color: .blue, - count: tasksResponse.upcomingTasks.count, - tasks: tasksResponse.upcomingTasks, - onEditTask: onEditTask, - onCancelTask: onCancelTask, - onUncancelTask: onUncancelTask, - onMarkInProgress: onMarkInProgress, - onCompleteTask: onCompleteTask, - onArchiveTask: onArchiveTask, - onUnarchiveTask: onUnarchiveTask - ) - .frame(width: geometry.size.width - 48) - - // In Progress Column - TaskColumnView( - title: "In Progress", - icon: "play.circle", - color: .orange, - count: tasksResponse.inProgressTasks.count, - tasks: tasksResponse.inProgressTasks, - onEditTask: onEditTask, - onCancelTask: onCancelTask, - onUncancelTask: onUncancelTask, - onMarkInProgress: nil, - onCompleteTask: onCompleteTask, - onArchiveTask: onArchiveTask, - onUnarchiveTask: onUnarchiveTask - ) - .frame(width: geometry.size.width - 48) - - // Done Column - TaskColumnView( - title: "Done", - icon: "checkmark.circle", - color: .green, - count: tasksResponse.doneTasks.count, - tasks: tasksResponse.doneTasks, - onEditTask: onEditTask, - onCancelTask: nil, - onUncancelTask: nil, - onMarkInProgress: nil, - onCompleteTask: nil, - onArchiveTask: onArchiveTask, - onUnarchiveTask: onUnarchiveTask - ) - .frame(width: geometry.size.width - 48) - - // Archived Column - TaskColumnView( - title: "Archived", - icon: "archivebox", - color: .gray, - count: tasksResponse.archivedTasks.count, - tasks: tasksResponse.archivedTasks, - onEditTask: onEditTask, - onCancelTask: nil, - onUncancelTask: nil, - onMarkInProgress: nil, - onCompleteTask: nil, - onArchiveTask: nil, - onUnarchiveTask: onUnarchiveTask - ) - .frame(width: geometry.size.width - 48) + // Dynamically create columns from response + ForEach(Array(tasksResponse.columns.enumerated()), id: \.element.name) { index, column in + DynamicTaskColumnView( + column: column, + onEditTask: { task in + onEditTask(task) + }, + onCancelTask: { taskId in + onCancelTask(taskId) + }, + onUncancelTask: { taskId in + onUncancelTask(taskId) + }, + onMarkInProgress: { taskId in + onMarkInProgress(taskId) + }, + onCompleteTask: { task in + onCompleteTask(task) + }, + onArchiveTask: { taskId in + onArchiveTask(taskId) + }, + onUnarchiveTask: { taskId in + onUnarchiveTask(taskId) + } + ) + .frame(width: geometry.size.width - 48) + } } .scrollTargetLayout() .padding(.horizontal, 16) @@ -104,69 +69,79 @@ struct TasksSection: View { #Preview { TasksSection( - tasksResponse: TasksByResidenceResponse( - residenceId: "1", + tasksResponse: TaskColumnsResponse( + columns: [ + TaskColumn( + name: "upcoming_tasks", + displayName: "Upcoming", + buttonTypes: ["edit", "cancel", "uncancel", "mark_in_progress", "complete", "archive"], + icons: ["ios": "calendar", "android": "CalendarToday", "web": "calendar"], + color: "#007AFF", + tasks: [ + TaskDetail( + id: 1, + residence: 1, + title: "Clean Gutters", + description: "Remove all debris", + category: TaskCategory(id: 1, name: "maintenance", description: ""), + priority: TaskPriority(id: 2, name: "medium", displayName: "Medium", description: ""), + frequency: TaskFrequency(id: 1, name: "monthly", displayName: "Monthly", daySpan: 0, notifyDays: 0), + status: TaskStatus(id: 1, name: "pending", displayName: "Pending", description: ""), + dueDate: "2024-12-15", + estimatedCost: "150.00", + actualCost: nil, + notes: nil, + archived: false, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + nextScheduledDate: nil, + showCompletedButton: true, + completions: [] + ) + ], + count: 1 + ), + TaskColumn( + name: "done_tasks", + displayName: "Done", + buttonTypes: ["edit", "archive"], + icons: ["ios": "checkmark.circle", "android": "CheckCircle", "web": "check-circle"], + color: "#34C759", + tasks: [ + TaskDetail( + id: 2, + residence: 1, + title: "Fix Leaky Faucet", + description: "Kitchen sink fixed", + category: TaskCategory(id: 2, name: "plumbing", description: ""), + priority: TaskPriority(id: 3, name: "high", displayName: "High", description: ""), + frequency: TaskFrequency(id: 6, name: "once", displayName: "One Time", daySpan: 0, notifyDays: 0), + status: TaskStatus(id: 3, name: "completed", displayName: "Completed", description: ""), + dueDate: "2024-11-01", + estimatedCost: "200.00", + actualCost: nil, + notes: nil, + archived: false, + createdAt: "2024-10-01T00:00:00Z", + updatedAt: "2024-11-05T00:00:00Z", + nextScheduledDate: nil, + showCompletedButton: false, + completions: [] + ) + ], + count: 1 + ) + ], daysThreshold: 30, - summary: CategorizedTaskSummary( - upcoming: 3, - inProgress: 1, - done: 2, - archived: 0 - ), - upcomingTasks: [ - TaskDetail( - id: 1, - residence: 1, - title: "Clean Gutters", - description: "Remove all debris", - category: TaskCategory(id: 1, name: "maintenance", description: "General upkeep tasks"), - priority: TaskPriority(id: 2, name: "medium", displayName: "Medium", description: "Standard priority"), - frequency: TaskFrequency(id: 1, name: "monthly", displayName: "Monthly", daySpan: 0, notifyDays: 0), - status: TaskStatus(id: 1, name: "pending", displayName: "Pending", description: "Awaiting completion"), - dueDate: "2024-12-15", - estimatedCost: "150.00", - actualCost: nil, - notes: nil, - archived: false, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - nextScheduledDate: nil, - showCompletedButton: true, - completions: [] - ) - ], - inProgressTasks: [], - doneTasks: [ - TaskDetail( - id: 2, - residence: 1, - title: "Fix Leaky Faucet", - description: "Kitchen sink fixed", - category: TaskCategory(id: 2, name: "plumbing", description: "Plumbing tasks"), - priority: TaskPriority(id: 3, name: "high", displayName: "High", description: "High priority"), - frequency: TaskFrequency(id: 6, name: "once", displayName: "One Time", daySpan: 0, notifyDays: 0), - status: TaskStatus(id: 3, name: "completed", displayName: "Completed", description: "Task completed"), - dueDate: "2024-11-01", - estimatedCost: "200.00", - actualCost: nil, - notes: nil, - archived: false, - createdAt: "2024-10-01T00:00:00Z", - updatedAt: "2024-11-05T00:00:00Z", - nextScheduledDate: nil, - showCompletedButton: false, - completions: [] - ) - ], - archivedTasks: [] + residenceId: "1" ), onEditTask: { _ in }, onCancelTask: { _ in }, onUncancelTask: { _ in }, onMarkInProgress: { _ in }, - onCompleteTask: { _ in } - , onArchiveTask: { _ in } - , onUnarchiveTask: { _ in } + onCompleteTask: { _ in }, + onArchiveTask: { _ in }, + onUnarchiveTask: { _ in } ) .padding() } diff --git a/iosApp/iosApp/Task/AddTaskView.swift b/iosApp/iosApp/Task/AddTaskView.swift index 0261bc3..18e3f51 100644 --- a/iosApp/iosApp/Task/AddTaskView.swift +++ b/iosApp/iosApp/Task/AddTaskView.swift @@ -214,6 +214,7 @@ struct AddTaskView: View { frequency: Int32(frequency.id), intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt, priority: Int32(priority.id), + status: selectedStatus.map { KotlinInt(value: $0.id) }, dueDate: dueDateString, estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost ) diff --git a/iosApp/iosApp/Task/AddTaskWithResidenceView.swift b/iosApp/iosApp/Task/AddTaskWithResidenceView.swift index d1581b3..2b4e7a6 100644 --- a/iosApp/iosApp/Task/AddTaskWithResidenceView.swift +++ b/iosApp/iosApp/Task/AddTaskWithResidenceView.swift @@ -237,6 +237,7 @@ struct AddTaskWithResidenceView: View { frequency: Int32(frequency.id), intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt, priority: Int32(priority.id), + status: selectedStatus.map { KotlinInt(value: $0.id) }, dueDate: dueDateString, estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost ) diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index 44b3bae..774f628 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -4,31 +4,28 @@ import ComposeApp struct AllTasksView: View { @StateObject private var taskViewModel = TaskViewModel() @StateObject private var residenceViewModel = ResidenceViewModel() - @State private var tasksResponse: AllTasksResponse? + @State private var tasksResponse: TaskColumnsResponse? @State private var isLoadingTasks = false @State private var tasksError: String? @State private var showAddTask = false @State private var showEditTask = false @State private var selectedTaskForEdit: TaskDetail? @State private var selectedTaskForComplete: TaskDetail? - + private var hasNoTasks: Bool { guard let response = tasksResponse else { return true } - return response.upcomingTasks.isEmpty && - response.inProgressTasks.isEmpty && - response.doneTasks.isEmpty && - response.archivedTasks.isEmpty + return response.columns.allSatisfy { $0.tasks.isEmpty } } - + private var hasTasks: Bool { !hasNoTasks } - + var body: some View { ZStack { Color(.systemGroupedBackground) .ignoresSafeArea() - + if isLoadingTasks { ProgressView() } else if let error = tasksError { @@ -40,20 +37,20 @@ struct AllTasksView: View { // Empty state with big button VStack(spacing: 24) { Spacer() - + Image(systemName: "checklist") .font(.system(size: 64)) .foregroundStyle(.blue.opacity(0.6)) - + Text("No tasks yet") .font(.title2) .fontWeight(.semibold) - + Text("Create your first task to get started") .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) - + Button(action: { showAddTask = true }) { @@ -69,13 +66,13 @@ struct AllTasksView: View { .controlSize(.large) .padding(.horizontal, 48) .disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true) - + if residenceViewModel.myResidences?.residences.isEmpty ?? true { Text("Add a property first from the Residences tab") .font(.caption) .foregroundColor(.red) } - + Spacer() } .padding() @@ -83,128 +80,47 @@ struct AllTasksView: View { GeometryReader { geometry in ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: 16) { - // Upcoming Column - TaskColumnView( - title: "Upcoming", - icon: "calendar", - color: .blue, - count: tasksResponse.upcomingTasks.count, - tasks: tasksResponse.upcomingTasks, - onEditTask: { task in - selectedTaskForEdit = task - showEditTask = true - }, - onCancelTask: { task in - taskViewModel.cancelTask(id: task.id) { _ in - loadAllTasks() - } - }, - onUncancelTask: { task in - taskViewModel.uncancelTask(id: task.id) { _ in - loadAllTasks() - } - }, - onMarkInProgress: { task in - taskViewModel.markInProgress(id: task.id) { success in - if success { + // Dynamically create columns from response + ForEach(Array(tasksResponse.columns.enumerated()), id: \.element.name) { index, column in + DynamicTaskColumnView( + column: column, + onEditTask: { task in + selectedTaskForEdit = task + showEditTask = true + }, + onCancelTask: { taskId in + taskViewModel.cancelTask(id: taskId) { _ in + loadAllTasks() + } + }, + onUncancelTask: { taskId in + taskViewModel.uncancelTask(id: taskId) { _ in + loadAllTasks() + } + }, + onMarkInProgress: { taskId in + taskViewModel.markInProgress(id: taskId) { success in + if success { + loadAllTasks() + } + } + }, + onCompleteTask: { task in + selectedTaskForComplete = task + }, + onArchiveTask: { taskId in + taskViewModel.archiveTask(id: taskId) { _ in + loadAllTasks() + } + }, + onUnarchiveTask: { taskId in + taskViewModel.unarchiveTask(id: taskId) { _ in loadAllTasks() } } - }, - onCompleteTask: { task in - selectedTaskForComplete = task - }, - onArchiveTask: { task in - taskViewModel.archiveTask(id: task.id) { _ in - loadAllTasks() - } - }, - onUnarchiveTask: nil - ) - .frame(width: geometry.size.width - 48) - - - // In Progress Column - TaskColumnView( - title: "In Progress", - icon: "play.circle", - color: .orange, - count: tasksResponse.inProgressTasks.count, - tasks: tasksResponse.inProgressTasks, - onEditTask: { task in - selectedTaskForEdit = task - showEditTask = true - }, - onCancelTask: { task in - taskViewModel.cancelTask(id: task.id) { _ in - loadAllTasks() - } - }, - onUncancelTask: { task in - taskViewModel.uncancelTask(id: task.id) { _ in - loadAllTasks() - } - }, - onMarkInProgress: nil, - onCompleteTask: { task in - selectedTaskForComplete = task - }, - onArchiveTask: { task in - taskViewModel.archiveTask(id: task.id) { _ in - loadAllTasks() - } - }, - onUnarchiveTask: nil - ) - .frame(width: geometry.size.width - 48) - - // Done Column - TaskColumnView( - title: "Done", - icon: "checkmark.circle", - color: .green, - count: tasksResponse.doneTasks.count, - tasks: tasksResponse.doneTasks, - onEditTask: { task in - selectedTaskForEdit = task - showEditTask = true - }, - onCancelTask: nil, - onUncancelTask: nil, - onMarkInProgress: nil, - onCompleteTask: nil, - onArchiveTask: { task in - taskViewModel.archiveTask(id: task.id) { _ in - loadAllTasks() - } - }, - onUnarchiveTask: nil - ) - .frame(width: geometry.size.width - 48) - - // Archived Column - TaskColumnView( - title: "Archived", - icon: "archivebox", - color: .gray, - count: tasksResponse.archivedTasks.count, - tasks: tasksResponse.archivedTasks, - onEditTask: { task in - selectedTaskForEdit = task - showEditTask = true - }, - onCancelTask: nil, - onUncancelTask: nil, - onMarkInProgress: nil, - onCompleteTask: nil, - onArchiveTask: nil, - onUnarchiveTask: { task in - taskViewModel.unarchiveTask(id: task.id) { _ in - loadAllTasks() - } - } - ) - .frame(width: geometry.size.width - 48) + ) + .frame(width: geometry.size.width - 48) + } } .scrollTargetLayout() .padding(.horizontal, 16) @@ -258,93 +174,100 @@ struct AllTasksView: View { loadAllTasks() residenceViewModel.loadMyResidences() } -} + } -private func loadAllTasks() { - guard let token = TokenStorage.shared.getToken() else { return } - - isLoadingTasks = true - tasksError = nil - - let taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient()) - taskApi.getTasks(token: token, days: 30) { 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 - self.isLoadingTasks = false - } else if let error = error { - self.tasksError = error.localizedDescription - self.isLoadingTasks = false + private func loadAllTasks() { + guard let token = TokenStorage.shared.getToken() else { return } + + isLoadingTasks = true + tasksError = nil + + let taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient()) + taskApi.getTasks(token: token, days: 30) { 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 + self.isLoadingTasks = false + } else if let error = error { + self.tasksError = error.localizedDescription + self.isLoadingTasks = false + } } } } -} -struct TaskColumnView: View { - let title: String - let icon: String - let color: Color - let count: Int - let tasks: [TaskDetail] +/// Dynamic task column view that adapts based on the column configuration +struct DynamicTaskColumnView: View { + let column: TaskColumn let onEditTask: (TaskDetail) -> Void - let onCancelTask: ((TaskDetail) -> Void)? - let onUncancelTask: ((TaskDetail) -> Void)? - let onMarkInProgress: ((TaskDetail) -> Void)? - let onCompleteTask: ((TaskDetail) -> Void)? - let onArchiveTask: ((TaskDetail) -> Void)? - let onUnarchiveTask: ((TaskDetail) -> Void)? + let onCancelTask: (Int32) -> Void + let onUncancelTask: (Int32) -> Void + let onMarkInProgress: (Int32) -> Void + let onCompleteTask: (TaskDetail) -> Void + let onArchiveTask: (Int32) -> Void + let onUnarchiveTask: (Int32) -> Void + + // Get icon from API response, with fallback + private var columnIcon: String { + column.icons["ios"] ?? "list.bullet" + } + + private var columnColor: Color { + Color(hex: column.color) ?? .primary + } var body: some View { VStack(spacing: 0) { - // Tasks List ScrollView { VStack(spacing: 16) { // Header HStack(spacing: 8) { - Image(systemName: icon) + Image(systemName: columnIcon) .font(.headline) - .foregroundColor(color) - - Text(title) + .foregroundColor(columnColor) + + Text(column.displayName) .font(.headline) - .foregroundColor(color) - + .foregroundColor(columnColor) + Spacer() - - Text("\(count)") + + Text("\(column.count)") .font(.caption) .fontWeight(.semibold) .foregroundColor(.white) .padding(.horizontal, 8) .padding(.vertical, 4) - .background(color) + .background(columnColor) .cornerRadius(12) } - - if tasks.isEmpty { + + if column.tasks.isEmpty { VStack(spacing: 8) { - Image(systemName: icon) + Image(systemName: columnIcon) .font(.system(size: 40)) - .foregroundColor(color.opacity(0.3)) - + .foregroundColor(columnColor.opacity(0.3)) + Text("No tasks") .font(.caption) .foregroundColor(.secondary) } .frame(maxWidth: .infinity) + .padding(.top, 40) } else { - ForEach(tasks, id: \.id) { task in - TaskCard( + ForEach(column.tasks, id: \.id) { task in + DynamicTaskCard( task: task, + buttonTypes: column.buttonTypes, onEdit: { onEditTask(task) }, - onCancel: onCancelTask != nil ? { onCancelTask?(task) } : nil, - onUncancel: onUncancelTask != nil ? { onUncancelTask?(task) } : nil, - onMarkInProgress: onMarkInProgress != nil ? { onMarkInProgress?(task) } : nil, - onComplete: onCompleteTask != nil ? { onCompleteTask?(task) } : nil, - onArchive: onArchiveTask != nil ? { onArchiveTask?(task) } : nil, - onUnarchive: onUnarchiveTask != nil ? { onUnarchiveTask?(task) } : nil + onCancel: { onCancelTask(task.id) }, + onUncancel: { onUncancelTask(task.id) }, + onMarkInProgress: { onMarkInProgress(task.id) }, + onComplete: { onCompleteTask(task) }, + onArchive: { onArchiveTask(task.id) }, + onUnarchive: { onUnarchiveTask(task.id) } ) } } @@ -354,6 +277,155 @@ struct TaskColumnView: View { } } +/// Task card that dynamically renders buttons based on the column's button types +struct DynamicTaskCard: View { + let task: TaskDetail + let buttonTypes: [String] + let onEdit: () -> Void + let onCancel: () -> Void + let onUncancel: () -> Void + let onMarkInProgress: () -> Void + let onComplete: () -> Void + let onArchive: () -> Void + let onUnarchive: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(task.title) + .font(.headline) + .foregroundColor(.primary) + + if let status = task.status { + StatusBadge(status: status.name) + } + } + + Spacer() + + PriorityBadge(priority: task.priority.name) + } + + if let description = task.description_, !description.isEmpty { + Text(description) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(2) + } + + HStack { + Label(task.frequency.displayName, systemImage: "repeat") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + Label(formatDate(task.dueDate), systemImage: "calendar") + .font(.caption) + .foregroundColor(.secondary) + } + + if task.completions.count > 0 { + Divider() + + HStack { + Image(systemName: "checkmark.circle") + .foregroundColor(.green) + Text("Completed \(task.completions.count) time\(task.completions.count == 1 ? "" : "s")") + .font(.caption) + .foregroundColor(.secondary) + } + } + + // Render buttons based on buttonTypes array + VStack(spacing: 8) { + ForEach(Array(buttonTypes.enumerated()), id: \.offset) { index, buttonType in + renderButton(for: buttonType) + } + } + } + .padding(16) + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.05), radius: 3, x: 0, y: 2) + } + + private func formatDate(_ dateString: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + if let date = formatter.date(from: dateString) { + formatter.dateStyle = .medium + return formatter.string(from: date) + } + return dateString + } + + @ViewBuilder + private func renderButton(for buttonType: String) -> some View { + switch buttonType { + case "mark_in_progress": + MarkInProgressButton( + taskId: task.id, + onCompletion: onMarkInProgress, + onError: { error in + print("Error marking in progress: \(error)") + } + ) + case "complete": + CompleteTaskButton( + taskId: task.id, + onCompletion: onComplete, + onError: { error in + print("Error completing task: \(error)") + } + ) + case "edit": + EditTaskButton( + taskId: task.id, + onCompletion: onEdit, + onError: { error in + print("Error editing task: \(error)") + } + ) + case "cancel": + CancelTaskButton( + taskId: task.id, + onCompletion: onCancel, + onError: { error in + print("Error cancelling task: \(error)") + } + ) + case "uncancel": + UncancelTaskButton( + taskId: task.id, + onCompletion: onUncancel, + onError: { error in + print("Error restoring task: \(error)") + } + ) + case "archive": + ArchiveTaskButton( + taskId: task.id, + onCompletion: onArchive, + onError: { error in + print("Error archiving task: \(error)") + } + ) + case "unarchive": + UnarchiveTaskButton( + taskId: task.id, + onCompletion: onUnarchive, + onError: { error in + print("Error unarchiving task: \(error)") + } + ) + default: + EmptyView() + } + } +} + // Extension to apply corner radius to specific corners extension View { func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { @@ -364,7 +436,7 @@ extension View { struct RoundedCorner: Shape { var radius: CGFloat = .infinity var corners: UIRectCorner = .allCorners - + func path(in rect: CGRect) -> Path { let path = UIBezierPath( roundedRect: rect, @@ -381,7 +453,6 @@ struct RoundedCorner: Shape { } } - extension Array where Element == ResidenceWithTasks { /// Converts an array of ResidenceWithTasks into an array of Residence. /// Adjust the mapping inside as needed to match your model initializers. @@ -414,3 +485,31 @@ extension Array where Element == ResidenceWithTasks { } } } + +extension Color { + /// Initialize Color from hex string (e.g., "#007AFF" or "007AFF") + init?(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + return nil + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} diff --git a/iosApp/iosApp/Task/EditTaskView.swift b/iosApp/iosApp/Task/EditTaskView.swift index a1e013e..6082a66 100644 --- a/iosApp/iosApp/Task/EditTaskView.swift +++ b/iosApp/iosApp/Task/EditTaskView.swift @@ -149,6 +149,7 @@ struct EditTaskView: View { frequency: frequency.id, intervalDays: nil, priority: priority.id, + status: KotlinInt(value: status.id), dueDate: dueDate, estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost )