This commit is contained in:
Trey t
2025-11-07 14:53:14 -06:00
parent 4e14352cd1
commit b922c4fb88
20 changed files with 1366 additions and 649 deletions

View File

@@ -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<String>,
val icons: Map<String, String>,
val color: String,
val tasks: List<TaskDetail>,
val count: Int
)
@Serializable
data class TaskColumnsResponse(
val columns: List<TaskColumn>,
@SerialName("days_threshold") val daysThreshold: Int? = null,
@SerialName("residence_id") val residenceId: String? = null
)

View File

@@ -12,7 +12,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTasks(
token: String,
days: Int = 30
): ApiResult<AllTasksResponse> {
): ApiResult<TaskColumnsResponse> {
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<TasksByResidenceResponse> {
): ApiResult<TaskColumnsResponse> {
return try {
val response = client.get("$baseUrl/tasks/by-residence/$residenceId/") {
header("Authorization", "Token $token")

View File

@@ -273,6 +273,7 @@ fun AddNewTaskDialog(
frequency = frequency.id,
intervalDays = intervalDays.toIntOrNull(),
priority = priority.id,
status = null,
dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null }
)

View File

@@ -330,6 +330,7 @@ fun AddNewTaskWithResidenceDialog(
frequency = frequency.id,
intervalDays = intervalDays.toIntOrNull(),
priority = priority.id,
status = null,
dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null }
)

View File

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

View File

@@ -20,6 +20,7 @@ import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun TaskCard(
task: TaskDetail,
buttonTypes: List<String> = 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")
}
}
}

View File

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

View File

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

View File

@@ -300,6 +300,7 @@ fun EditTaskScreen(
category = selectedCategory!!.id,
frequency = selectedFrequency!!.id,
priority = selectedPriority!!.id,
status = selectedStatus!!.id,
dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null }
)

View File

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

View File

@@ -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<String>()) }
var showCompleteDialog by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<com.mycrib.shared.models.TaskDetail?>(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
}
}

View File

@@ -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<Residence>>(ApiResult.Idle)
val updateResidenceState: StateFlow<ApiResult<Residence>> = _updateResidenceState
private val _residenceTasksState = MutableStateFlow<ApiResult<TasksByResidenceResponse>>(ApiResult.Idle)
val residenceTasksState: StateFlow<ApiResult<TasksByResidenceResponse>> = _residenceTasksState
private val _residenceTasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _residenceTasksState
private val _myResidencesState = MutableStateFlow<ApiResult<MyResidencesResponse>>(ApiResult.Idle)
val myResidencesState: StateFlow<ApiResult<MyResidencesResponse>> = _myResidencesState

View File

@@ -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<AllTasksResponse>>(ApiResult.Idle)
val tasksState: StateFlow<ApiResult<AllTasksResponse>> = _tasksState
private val _tasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
val tasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _tasksState
private val _tasksByResidenceState = MutableStateFlow<ApiResult<TasksByResidenceResponse>>(ApiResult.Idle)
val tasksByResidenceState: StateFlow<ApiResult<TasksByResidenceResponse>> = _tasksByResidenceState
private val _tasksByResidenceState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
val tasksByResidenceState: StateFlow<ApiResult<TaskColumnsResponse>> = _tasksByResidenceState
private val _taskAddNewCustomTaskState = MutableStateFlow<ApiResult<CustomTask>>(ApiResult.Idle)
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _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()

View File

@@ -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<TasksByResidenceResponse> {
if let successResult = result as? ApiResultSuccess<TaskColumnsResponse> {
self.tasksResponse = successResult.data
self.isLoadingTasks = false
} else if let errorResult = result as? ApiResultError {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<AllTasksResponse> {
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<TaskColumnsResponse> {
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
)
}
}

View File

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