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, val frequency: Int,
@SerialName("interval_days") val intervalDays: Int? = null, @SerialName("interval_days") val intervalDays: Int? = null,
val priority: Int, val priority: Int,
val status: Int? = null,
@SerialName("due_date") val dueDate: String, @SerialName("due_date") val dueDate: String,
@SerialName("estimated_cost") val estimatedCost: String? = null @SerialName("estimated_cost") val estimatedCost: String? = null
) )
@@ -106,3 +107,21 @@ data class TaskCancelResponse(
val message: String, val message: String,
val task: TaskDetail 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( suspend fun getTasks(
token: String, token: String,
days: Int = 30 days: Int = 30
): ApiResult<AllTasksResponse> { ): ApiResult<TaskColumnsResponse> {
return try { return try {
val response = client.get("$baseUrl/tasks/") { val response = client.get("$baseUrl/tasks/") {
header("Authorization", "Token $token") header("Authorization", "Token $token")
@@ -101,7 +101,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
token: String, token: String,
residenceId: Int, residenceId: Int,
days: Int = 30 days: Int = 30
): ApiResult<TasksByResidenceResponse> { ): ApiResult<TaskColumnsResponse> {
return try { return try {
val response = client.get("$baseUrl/tasks/by-residence/$residenceId/") { val response = client.get("$baseUrl/tasks/by-residence/$residenceId/") {
header("Authorization", "Token $token") header("Authorization", "Token $token")

View File

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

View File

@@ -330,6 +330,7 @@ fun AddNewTaskWithResidenceDialog(
frequency = frequency.id, frequency = frequency.id,
intervalDays = intervalDays.toIntOrNull(), intervalDays = intervalDays.toIntOrNull(),
priority = priority.id, priority = priority.id,
status = null,
dueDate = dueDate, dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null } 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 @Composable
fun TaskCard( fun TaskCard(
task: TaskDetail, task: TaskDetail,
buttonTypes: List<String> = emptyList(),
onCompleteClick: (() -> Unit)?, onCompleteClick: (() -> Unit)?,
onEditClick: () -> Unit, onEditClick: () -> Unit,
onCancelClick: (() -> Unit)?, onCancelClick: (() -> Unit)?,
@@ -243,161 +244,87 @@ fun TaskCard(
} }
} }
// Show complete task button and mark in progress button // Render buttons based on buttonTypes array
if ((task.showCompletedButton && onCompleteClick != null) || (onMarkInProgressClick != null && task.status?.name != "in_progress")) { if (buttonTypes.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp)) 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 buttonTypes.forEach { buttonType ->
if (task.showCompletedButton && onCompleteClick != null) { when (buttonType) {
Button( "mark_in_progress" -> {
onClick = onCompleteClick, onMarkInProgressClick?.let {
modifier = Modifier.weight(1f), MarkInProgressButton(
shape = RoundedCornerShape(12.dp) taskId = task.id,
) { onCompletion = it,
Icon( onError = { error -> println("Error: $error") },
Icons.Default.CheckCircle, modifier = Modifier.fillMaxWidth()
contentDescription = null,
modifier = Modifier.size(18.dp)
) )
Spacer(modifier = Modifier.width(4.dp))
Text(
"Complete",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
// Action buttons row
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Edit button
OutlinedButton(
onClick = onEditClick,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp)
) {
Icon(
Icons.Default.Edit,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Edit")
}
// Cancel or Uncancel button
when {
onCancelClick != null -> {
OutlinedButton(
onClick = onCancelClick,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
Icons.Default.Cancel,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Cancel")
}
}
onUncancelClick != null -> {
Button(
onClick = onUncancelClick,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
)
) {
Icon(
Icons.Default.Undo,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Restore")
}
}
}
}
// Archive/Unarchive button row
if (task.archived) {
if (onUnarchiveClick != null) {
Spacer(modifier = Modifier.height(8.dp)) 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 { "complete" -> {
if (onArchiveClick != null) { onCompleteClick?.let {
CompleteTaskButton(
taskId = task.id,
onCompletion = it,
onError = { error -> println("Error: $error") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
OutlinedButton( }
onClick = onArchiveClick, }
modifier = Modifier.fillMaxWidth(), "edit" -> {
shape = RoundedCornerShape(12.dp), EditTaskButton(
colors = ButtonDefaults.outlinedButtonColors( taskId = task.id,
contentColor = MaterialTheme.colorScheme.outline onCompletion = onEditClick,
onError = { error -> println("Error: $error") },
modifier = Modifier.fillMaxWidth()
) )
) { Spacer(modifier = Modifier.height(8.dp))
Icon( }
Icons.Default.Archive, "cancel" -> {
contentDescription = null, onCancelClick?.let {
modifier = Modifier.size(18.dp) CancelTaskButton(
taskId = task.id,
onCompletion = it,
onError = { error -> println("Error: $error") },
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.height(8.dp))
Text("Archive") }
}
"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))
}
}
} }
} }
} }

View File

@@ -19,7 +19,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.mycrib.shared.models.AllTasksResponse import com.mycrib.shared.models.TaskColumn
import com.mycrib.shared.models.TaskDetail import com.mycrib.shared.models.TaskDetail
@OptIn(ExperimentalFoundationApi::class) @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.AddNewTaskWithResidenceDialog
import com.mycrib.android.ui.components.CompleteTaskDialog import com.mycrib.android.ui.components.CompleteTaskDialog
import com.mycrib.android.ui.components.task.TaskCard 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.ResidenceViewModel
import com.mycrib.android.viewmodel.TaskCompletionViewModel import com.mycrib.android.viewmodel.TaskCompletionViewModel
import com.mycrib.android.viewmodel.TaskViewModel import com.mycrib.android.viewmodel.TaskViewModel
@@ -149,10 +149,7 @@ fun AllTasksScreen(
} }
is ApiResult.Success -> { is ApiResult.Success -> {
val taskData = (tasksState as ApiResult.Success).data val taskData = (tasksState as ApiResult.Success).data
val hasNoTasks = taskData.upcomingTasks.isEmpty() && val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() }
taskData.inProgressTasks.isEmpty() &&
taskData.doneTasks.isEmpty() &&
taskData.archivedTasks.isEmpty()
if (hasNoTasks) { if (hasNoTasks) {
Box( Box(
@@ -219,11 +216,8 @@ fun AllTasksScreen(
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) { ) {
TaskKanbanView( DynamicTaskKanbanView(
upcomingTasks = taskData.upcomingTasks, columns = taskData.columns,
inProgressTasks = taskData.inProgressTasks,
doneTasks = taskData.doneTasks,
archivedTasks = taskData.archivedTasks,
onCompleteTask = { task -> onCompleteTask = { task ->
selectedTask = task selectedTask = task
showCompleteDialog = true showCompleteDialog = true

View File

@@ -300,6 +300,7 @@ fun EditTaskScreen(
category = selectedCategory!!.id, category = selectedCategory!!.id,
frequency = selectedFrequency!!.id, frequency = selectedFrequency!!.id,
priority = selectedPriority!!.id, priority = selectedPriority!!.id,
status = selectedStatus!!.id,
dueDate = dueDate, dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null } 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.PropertyDetailItem
import com.mycrib.android.ui.components.residence.DetailRow import com.mycrib.android.ui.components.residence.DetailRow
import com.mycrib.android.ui.components.task.TaskCard 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.ResidenceViewModel
import com.mycrib.android.viewmodel.TaskCompletionViewModel import com.mycrib.android.viewmodel.TaskCompletionViewModel
import com.mycrib.android.viewmodel.TaskViewModel import com.mycrib.android.viewmodel.TaskViewModel
@@ -393,7 +393,8 @@ fun ResidenceDetailScreen(
} }
is ApiResult.Success -> { is ApiResult.Success -> {
val taskData = (tasksState as ApiResult.Success).data 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 { item {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -432,11 +433,8 @@ fun ResidenceDetailScreen(
.fillMaxWidth() .fillMaxWidth()
.height(500.dp) .height(500.dp)
) { ) {
TaskKanbanView( DynamicTaskKanbanView(
upcomingTasks = taskData.upcomingTasks, columns = taskData.columns,
inProgressTasks = taskData.inProgressTasks,
doneTasks = taskData.doneTasks,
archivedTasks = taskData.archivedTasks,
onCompleteTask = { task -> onCompleteTask = { task ->
selectedTask = task selectedTask = task
showCompleteDialog = true showCompleteDialog = true

View File

@@ -26,8 +26,7 @@ fun TasksScreen(
) { ) {
val tasksState by viewModel.tasksState.collectAsState() val tasksState by viewModel.tasksState.collectAsState()
val completionState by taskCompletionViewModel.createCompletionState.collectAsState() val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
var showInProgressTasks by remember { mutableStateOf(false) } var expandedColumns by remember { mutableStateOf(setOf<String>()) }
var showDoneTasks by remember { mutableStateOf(false) }
var showCompleteDialog by remember { mutableStateOf(false) } var showCompleteDialog by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<com.mycrib.shared.models.TaskDetail?>(null) } var selectedTask by remember { mutableStateOf<com.mycrib.shared.models.TaskDetail?>(null) }
@@ -93,9 +92,7 @@ fun TasksScreen(
} }
is ApiResult.Success -> { is ApiResult.Success -> {
val taskData = (tasksState as ApiResult.Success).data val taskData = (tasksState as ApiResult.Success).data
val hasNoTasks = taskData.upcomingTasks.isEmpty() && val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() }
taskData.inProgressTasks.isEmpty() &&
taskData.doneTasks.isEmpty()
if (hasNoTasks) { if (hasNoTasks) {
Box( Box(
@@ -140,43 +137,37 @@ fun TasksScreen(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// Task summary pills // Task summary pills - dynamically generated from all columns
item { item {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
taskData.columns.forEach { column ->
TaskPill( TaskPill(
count = taskData.summary.upcoming, count = column.count,
label = "Upcoming", label = column.displayName,
color = MaterialTheme.colorScheme.primary color = hexToColor(column.color)
)
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 // Dynamically render all columns
if (taskData.upcomingTasks.isNotEmpty()) { 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 { item {
Text( Text(
text = "Upcoming (${taskData.upcomingTasks.size})", text = "${column.displayName} (${column.tasks.size})",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 8.dp) modifier = Modifier.padding(top = 8.dp)
) )
} }
}
// Upcoming tasks items(column.tasks) { task ->
items(taskData.upcomingTasks) { task ->
TaskCard( TaskCard(
task = task, task = task,
onCompleteClick = { onCompleteClick = {
@@ -188,13 +179,20 @@ fun TasksScreen(
onUncancelClick = { } onUncancelClick = { }
) )
} }
} else {
// Other columns - collapsible
val isExpanded = expandedColumns.contains(column.name)
// In Progress section (collapsible)
if (taskData.inProgressTasks.isNotEmpty()) {
item { item {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onClick = { showInProgressTasks = !showInProgressTasks } onClick = {
expandedColumns = if (isExpanded) {
expandedColumns - column.name
} else {
expandedColumns + column.name
}
}
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -208,85 +206,38 @@ fun TasksScreen(
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) { ) {
Icon( Icon(
Icons.Default.PlayArrow, getIconFromName(column.icons["android"] ?: "List"),
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary tint = hexToColor(column.color)
) )
Text( Text(
text = "In Progress (${taskData.inProgressTasks.size})", text = "${column.displayName} (${column.tasks.size})",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
} }
Icon( Icon(
if (showInProgressTasks) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = if (showInProgressTasks) "Collapse" else "Expand" contentDescription = if (isExpanded) "Collapse" else "Expand"
) )
} }
} }
} }
if (showInProgressTasks) { if (isExpanded) {
items(taskData.inProgressTasks) { task -> items(column.tasks) { task ->
TaskCard( TaskCard(
task = task, task = task,
onCompleteClick = { onCompleteClick = {
selectedTask = task selectedTask = task
showCompleteDialog = true showCompleteDialog = true
}, },
onEditClick = { /* TODO */ }, onEditClick = { },
onCancelClick = {}, onCancelClick = { },
onUncancelClick = {} onUncancelClick = { }
) )
} }
} }
} }
// 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"
)
}
}
}
if (showDoneTasks) {
items(taskData.doneTasks) { task ->
TaskCard(
task = task,
onCompleteClick = { /* TODO */ },
onEditClick = { /* TODO */ },
onUncancelClick = {},
onCancelClick = {}
)
}
} }
} }
} }
@@ -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.ResidenceCreateRequest
import com.mycrib.shared.models.ResidenceSummaryResponse import com.mycrib.shared.models.ResidenceSummaryResponse
import com.mycrib.shared.models.MyResidencesResponse 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.ApiResult
import com.mycrib.shared.network.ResidenceApi import com.mycrib.shared.network.ResidenceApi
import com.mycrib.shared.network.TaskApi import com.mycrib.shared.network.TaskApi
@@ -31,8 +31,8 @@ class ResidenceViewModel : ViewModel() {
private val _updateResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Idle) private val _updateResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Idle)
val updateResidenceState: StateFlow<ApiResult<Residence>> = _updateResidenceState val updateResidenceState: StateFlow<ApiResult<Residence>> = _updateResidenceState
private val _residenceTasksState = MutableStateFlow<ApiResult<TasksByResidenceResponse>>(ApiResult.Idle) private val _residenceTasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
val residenceTasksState: StateFlow<ApiResult<TasksByResidenceResponse>> = _residenceTasksState val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _residenceTasksState
private val _myResidencesState = MutableStateFlow<ApiResult<MyResidencesResponse>>(ApiResult.Idle) private val _myResidencesState = MutableStateFlow<ApiResult<MyResidencesResponse>>(ApiResult.Idle)
val myResidencesState: StateFlow<ApiResult<MyResidencesResponse>> = _myResidencesState val myResidencesState: StateFlow<ApiResult<MyResidencesResponse>> = _myResidencesState

View File

@@ -2,10 +2,9 @@ package com.mycrib.android.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.CustomTask
import com.mycrib.shared.models.TaskCreateRequest import com.mycrib.shared.models.TaskCreateRequest
import com.mycrib.shared.models.TasksByResidenceResponse
import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.ApiResult
import com.mycrib.shared.network.TaskApi import com.mycrib.shared.network.TaskApi
import com.mycrib.storage.TokenStorage import com.mycrib.storage.TokenStorage
@@ -16,11 +15,11 @@ import kotlinx.coroutines.launch
class TaskViewModel : ViewModel() { class TaskViewModel : ViewModel() {
private val taskApi = TaskApi() private val taskApi = TaskApi()
private val _tasksState = MutableStateFlow<ApiResult<AllTasksResponse>>(ApiResult.Idle) private val _tasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
val tasksState: StateFlow<ApiResult<AllTasksResponse>> = _tasksState val tasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _tasksState
private val _tasksByResidenceState = MutableStateFlow<ApiResult<TasksByResidenceResponse>>(ApiResult.Idle) private val _tasksByResidenceState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
val tasksByResidenceState: StateFlow<ApiResult<TasksByResidenceResponse>> = _tasksByResidenceState val tasksByResidenceState: StateFlow<ApiResult<TaskColumnsResponse>> = _tasksByResidenceState
private val _taskAddNewCustomTaskState = MutableStateFlow<ApiResult<CustomTask>>(ApiResult.Idle) private val _taskAddNewCustomTaskState = MutableStateFlow<ApiResult<CustomTask>>(ApiResult.Idle)
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState
@@ -74,6 +73,48 @@ class TaskViewModel : ViewModel() {
_taskAddNewCustomTaskState.value = ApiResult.Idle _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) { fun markInProgress(taskId: Int, onComplete: (Boolean) -> Unit) {
viewModelScope.launch { viewModelScope.launch {
val token = TokenStorage.getToken() val token = TokenStorage.getToken()

View File

@@ -5,7 +5,7 @@ struct ResidenceDetailView: View {
let residenceId: Int32 let residenceId: Int32
@StateObject private var viewModel = ResidenceViewModel() @StateObject private var viewModel = ResidenceViewModel()
@StateObject private var taskViewModel = TaskViewModel() @StateObject private var taskViewModel = TaskViewModel()
@State private var tasksResponse: TasksByResidenceResponse? @State private var tasksResponse: TaskColumnsResponse?
@State private var isLoadingTasks = false @State private var isLoadingTasks = false
@State private var tasksError: String? @State private var tasksError: String?
@State private var showAddTask = false @State private var showAddTask = false
@@ -41,18 +41,18 @@ struct ResidenceDetailView: View {
selectedTaskForEdit = task selectedTaskForEdit = task
showEditTask = true showEditTask = true
}, },
onCancelTask: { task in onCancelTask: { taskId in
taskViewModel.cancelTask(id: task.id) { _ in taskViewModel.cancelTask(id: taskId) { _ in
loadResidenceTasks() loadResidenceTasks()
} }
}, },
onUncancelTask: { task in onUncancelTask: { taskId in
taskViewModel.uncancelTask(id: task.id) { _ in taskViewModel.uncancelTask(id: taskId) { _ in
loadResidenceTasks() loadResidenceTasks()
} }
}, },
onMarkInProgress: { task in onMarkInProgress: { taskId in
taskViewModel.markInProgress(id: task.id) { success in taskViewModel.markInProgress(id: taskId) { success in
if success { if success {
loadResidenceTasks() loadResidenceTasks()
} }
@@ -61,13 +61,13 @@ struct ResidenceDetailView: View {
onCompleteTask: { task in onCompleteTask: { task in
selectedTaskForComplete = task selectedTaskForComplete = task
}, },
onArchiveTask: { task in onArchiveTask: { taskId in
taskViewModel.archiveTask(id: task.id) { _ in taskViewModel.archiveTask(id: taskId) { _ in
loadResidenceTasks() loadResidenceTasks()
} }
}, },
onUnarchiveTask: { task in onUnarchiveTask: { taskId in
taskViewModel.unarchiveTask(id: task.id) { _ in taskViewModel.unarchiveTask(id: taskId) { _ in
loadResidenceTasks() loadResidenceTasks()
} }
} }
@@ -158,7 +158,7 @@ struct ResidenceDetailView: View {
let taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient()) let taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient())
taskApi.getTasksByResidence(token: token, residenceId: residenceId, days: 30) { result, error in 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.tasksResponse = successResult.data
self.isLoadingTasks = false self.isLoadingTasks = false
} else if let errorResult = result as? ApiResultError { } 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 import ComposeApp
struct TasksSection: View { struct TasksSection: View {
let tasksResponse: TasksByResidenceResponse let tasksResponse: TaskColumnsResponse
let onEditTask: (TaskDetail) -> Void let onEditTask: (TaskDetail) -> Void
let onCancelTask: (TaskDetail) -> Void let onCancelTask: (Int32) -> Void
let onUncancelTask: (TaskDetail) -> Void let onUncancelTask: (Int32) -> Void
let onMarkInProgress: (TaskDetail) -> Void let onMarkInProgress: (Int32) -> Void
let onCompleteTask: (TaskDetail) -> Void let onCompleteTask: (TaskDetail) -> Void
let onArchiveTask: (TaskDetail) -> Void let onArchiveTask: (Int32) -> Void
let onUnarchiveTask: (TaskDetail) -> Void let onUnarchiveTask: (Int32) -> Void
private var hasNoTasks: Bool {
tasksResponse.columns.allSatisfy { $0.tasks.isEmpty }
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -17,80 +21,41 @@ struct TasksSection: View {
.font(.title2) .font(.title2)
.fontWeight(.bold) .fontWeight(.bold)
if tasksResponse.upcomingTasks.isEmpty && tasksResponse.inProgressTasks.isEmpty && tasksResponse.doneTasks.isEmpty && tasksResponse.archivedTasks.isEmpty { if hasNoTasks {
EmptyTasksView() EmptyTasksView()
} else { } else {
GeometryReader { geometry in GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 16) { LazyHStack(spacing: 16) {
// Upcoming Column // Dynamically create columns from response
TaskColumnView( ForEach(Array(tasksResponse.columns.enumerated()), id: \.element.name) { index, column in
title: "Upcoming", DynamicTaskColumnView(
icon: "calendar", column: column,
color: .blue, onEditTask: { task in
count: tasksResponse.upcomingTasks.count, onEditTask(task)
tasks: tasksResponse.upcomingTasks, },
onEditTask: onEditTask, onCancelTask: { taskId in
onCancelTask: onCancelTask, onCancelTask(taskId)
onUncancelTask: onUncancelTask, },
onMarkInProgress: onMarkInProgress, onUncancelTask: { taskId in
onCompleteTask: onCompleteTask, onUncancelTask(taskId)
onArchiveTask: onArchiveTask, },
onUnarchiveTask: onUnarchiveTask onMarkInProgress: { taskId in
) onMarkInProgress(taskId)
.frame(width: geometry.size.width - 48) },
onCompleteTask: { task in
// In Progress Column onCompleteTask(task)
TaskColumnView( },
title: "In Progress", onArchiveTask: { taskId in
icon: "play.circle", onArchiveTask(taskId)
color: .orange, },
count: tasksResponse.inProgressTasks.count, onUnarchiveTask: { taskId in
tasks: tasksResponse.inProgressTasks, onUnarchiveTask(taskId)
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) .frame(width: geometry.size.width - 48)
} }
}
.scrollTargetLayout() .scrollTargetLayout()
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
@@ -104,25 +69,24 @@ struct TasksSection: View {
#Preview { #Preview {
TasksSection( TasksSection(
tasksResponse: TasksByResidenceResponse( tasksResponse: TaskColumnsResponse(
residenceId: "1", columns: [
daysThreshold: 30, TaskColumn(
summary: CategorizedTaskSummary( name: "upcoming_tasks",
upcoming: 3, displayName: "Upcoming",
inProgress: 1, buttonTypes: ["edit", "cancel", "uncancel", "mark_in_progress", "complete", "archive"],
done: 2, icons: ["ios": "calendar", "android": "CalendarToday", "web": "calendar"],
archived: 0 color: "#007AFF",
), tasks: [
upcomingTasks: [
TaskDetail( TaskDetail(
id: 1, id: 1,
residence: 1, residence: 1,
title: "Clean Gutters", title: "Clean Gutters",
description: "Remove all debris", description: "Remove all debris",
category: TaskCategory(id: 1, name: "maintenance", description: "General upkeep tasks"), category: TaskCategory(id: 1, name: "maintenance", description: ""),
priority: TaskPriority(id: 2, name: "medium", displayName: "Medium", description: "Standard priority"), priority: TaskPriority(id: 2, name: "medium", displayName: "Medium", description: ""),
frequency: TaskFrequency(id: 1, name: "monthly", displayName: "Monthly", daySpan: 0, notifyDays: 0), frequency: TaskFrequency(id: 1, name: "monthly", displayName: "Monthly", daySpan: 0, notifyDays: 0),
status: TaskStatus(id: 1, name: "pending", displayName: "Pending", description: "Awaiting completion"), status: TaskStatus(id: 1, name: "pending", displayName: "Pending", description: ""),
dueDate: "2024-12-15", dueDate: "2024-12-15",
estimatedCost: "150.00", estimatedCost: "150.00",
actualCost: nil, actualCost: nil,
@@ -135,17 +99,24 @@ struct TasksSection: View {
completions: [] completions: []
) )
], ],
inProgressTasks: [], count: 1
doneTasks: [ ),
TaskColumn(
name: "done_tasks",
displayName: "Done",
buttonTypes: ["edit", "archive"],
icons: ["ios": "checkmark.circle", "android": "CheckCircle", "web": "check-circle"],
color: "#34C759",
tasks: [
TaskDetail( TaskDetail(
id: 2, id: 2,
residence: 1, residence: 1,
title: "Fix Leaky Faucet", title: "Fix Leaky Faucet",
description: "Kitchen sink fixed", description: "Kitchen sink fixed",
category: TaskCategory(id: 2, name: "plumbing", description: "Plumbing tasks"), category: TaskCategory(id: 2, name: "plumbing", description: ""),
priority: TaskPriority(id: 3, name: "high", displayName: "High", description: "High priority"), priority: TaskPriority(id: 3, name: "high", displayName: "High", description: ""),
frequency: TaskFrequency(id: 6, name: "once", displayName: "One Time", daySpan: 0, notifyDays: 0), frequency: TaskFrequency(id: 6, name: "once", displayName: "One Time", daySpan: 0, notifyDays: 0),
status: TaskStatus(id: 3, name: "completed", displayName: "Completed", description: "Task completed"), status: TaskStatus(id: 3, name: "completed", displayName: "Completed", description: ""),
dueDate: "2024-11-01", dueDate: "2024-11-01",
estimatedCost: "200.00", estimatedCost: "200.00",
actualCost: nil, actualCost: nil,
@@ -158,15 +129,19 @@ struct TasksSection: View {
completions: [] completions: []
) )
], ],
archivedTasks: [] count: 1
)
],
daysThreshold: 30,
residenceId: "1"
), ),
onEditTask: { _ in }, onEditTask: { _ in },
onCancelTask: { _ in }, onCancelTask: { _ in },
onUncancelTask: { _ in }, onUncancelTask: { _ in },
onMarkInProgress: { _ in }, onMarkInProgress: { _ in },
onCompleteTask: { _ in } onCompleteTask: { _ in },
, onArchiveTask: { _ in } onArchiveTask: { _ in },
, onUnarchiveTask: { _ in } onUnarchiveTask: { _ in }
) )
.padding() .padding()
} }

View File

@@ -214,6 +214,7 @@ struct AddTaskView: View {
frequency: Int32(frequency.id), frequency: Int32(frequency.id),
intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt, intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt,
priority: Int32(priority.id), priority: Int32(priority.id),
status: selectedStatus.map { KotlinInt(value: $0.id) },
dueDate: dueDateString, dueDate: dueDateString,
estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost
) )

View File

@@ -237,6 +237,7 @@ struct AddTaskWithResidenceView: View {
frequency: Int32(frequency.id), frequency: Int32(frequency.id),
intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt, intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt,
priority: Int32(priority.id), priority: Int32(priority.id),
status: selectedStatus.map { KotlinInt(value: $0.id) },
dueDate: dueDateString, dueDate: dueDateString,
estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost
) )

View File

@@ -4,7 +4,7 @@ import ComposeApp
struct AllTasksView: View { struct AllTasksView: View {
@StateObject private var taskViewModel = TaskViewModel() @StateObject private var taskViewModel = TaskViewModel()
@StateObject private var residenceViewModel = ResidenceViewModel() @StateObject private var residenceViewModel = ResidenceViewModel()
@State private var tasksResponse: AllTasksResponse? @State private var tasksResponse: TaskColumnsResponse?
@State private var isLoadingTasks = false @State private var isLoadingTasks = false
@State private var tasksError: String? @State private var tasksError: String?
@State private var showAddTask = false @State private var showAddTask = false
@@ -14,10 +14,7 @@ struct AllTasksView: View {
private var hasNoTasks: Bool { private var hasNoTasks: Bool {
guard let response = tasksResponse else { return true } guard let response = tasksResponse else { return true }
return response.upcomingTasks.isEmpty && return response.columns.allSatisfy { $0.tasks.isEmpty }
response.inProgressTasks.isEmpty &&
response.doneTasks.isEmpty &&
response.archivedTasks.isEmpty
} }
private var hasTasks: Bool { private var hasTasks: Bool {
@@ -83,29 +80,26 @@ struct AllTasksView: View {
GeometryReader { geometry in GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 16) { LazyHStack(spacing: 16) {
// Upcoming Column // Dynamically create columns from response
TaskColumnView( ForEach(Array(tasksResponse.columns.enumerated()), id: \.element.name) { index, column in
title: "Upcoming", DynamicTaskColumnView(
icon: "calendar", column: column,
color: .blue,
count: tasksResponse.upcomingTasks.count,
tasks: tasksResponse.upcomingTasks,
onEditTask: { task in onEditTask: { task in
selectedTaskForEdit = task selectedTaskForEdit = task
showEditTask = true showEditTask = true
}, },
onCancelTask: { task in onCancelTask: { taskId in
taskViewModel.cancelTask(id: task.id) { _ in taskViewModel.cancelTask(id: taskId) { _ in
loadAllTasks() loadAllTasks()
} }
}, },
onUncancelTask: { task in onUncancelTask: { taskId in
taskViewModel.uncancelTask(id: task.id) { _ in taskViewModel.uncancelTask(id: taskId) { _ in
loadAllTasks() loadAllTasks()
} }
}, },
onMarkInProgress: { task in onMarkInProgress: { taskId in
taskViewModel.markInProgress(id: task.id) { success in taskViewModel.markInProgress(id: taskId) { success in
if success { if success {
loadAllTasks() loadAllTasks()
} }
@@ -114,98 +108,20 @@ struct AllTasksView: View {
onCompleteTask: { task in onCompleteTask: { task in
selectedTaskForComplete = task selectedTaskForComplete = task
}, },
onArchiveTask: { task in onArchiveTask: { taskId in
taskViewModel.archiveTask(id: task.id) { _ in taskViewModel.archiveTask(id: taskId) { _ in
loadAllTasks() loadAllTasks()
} }
}, },
onUnarchiveTask: nil onUnarchiveTask: { taskId in
) taskViewModel.unarchiveTask(id: taskId) { _ in
.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() loadAllTasks()
} }
} }
) )
.frame(width: geometry.size.width - 48) .frame(width: geometry.size.width - 48)
} }
}
.scrollTargetLayout() .scrollTargetLayout()
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
@@ -258,9 +174,9 @@ struct AllTasksView: View {
loadAllTasks() loadAllTasks()
residenceViewModel.loadMyResidences() residenceViewModel.loadMyResidences()
} }
} }
private func loadAllTasks() { private func loadAllTasks() {
guard let token = TokenStorage.shared.getToken() else { return } guard let token = TokenStorage.shared.getToken() else { return }
isLoadingTasks = true isLoadingTasks = true
@@ -268,7 +184,7 @@ private func loadAllTasks() {
let taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient()) let taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient())
taskApi.getTasks(token: token, days: 30) { result, error in taskApi.getTasks(token: token, days: 30) { result, error in
if let successResult = result as? ApiResultSuccess<AllTasksResponse> { if let successResult = result as? ApiResultSuccess<TaskColumnsResponse> {
self.tasksResponse = successResult.data self.tasksResponse = successResult.data
self.isLoadingTasks = false self.isLoadingTasks = false
} else if let errorResult = result as? ApiResultError { } else if let errorResult = result as? ApiResultError {
@@ -279,72 +195,79 @@ private func loadAllTasks() {
self.isLoadingTasks = false self.isLoadingTasks = false
} }
} }
} }
} }
struct TaskColumnView: View { /// Dynamic task column view that adapts based on the column configuration
let title: String struct DynamicTaskColumnView: View {
let icon: String let column: TaskColumn
let color: Color
let count: Int
let tasks: [TaskDetail]
let onEditTask: (TaskDetail) -> Void let onEditTask: (TaskDetail) -> Void
let onCancelTask: ((TaskDetail) -> Void)? let onCancelTask: (Int32) -> Void
let onUncancelTask: ((TaskDetail) -> Void)? let onUncancelTask: (Int32) -> Void
let onMarkInProgress: ((TaskDetail) -> Void)? let onMarkInProgress: (Int32) -> Void
let onCompleteTask: ((TaskDetail) -> Void)? let onCompleteTask: (TaskDetail) -> Void
let onArchiveTask: ((TaskDetail) -> Void)? let onArchiveTask: (Int32) -> Void
let onUnarchiveTask: ((TaskDetail) -> 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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Tasks List
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
// Header // Header
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: icon) Image(systemName: columnIcon)
.font(.headline) .font(.headline)
.foregroundColor(color) .foregroundColor(columnColor)
Text(title) Text(column.displayName)
.font(.headline) .font(.headline)
.foregroundColor(color) .foregroundColor(columnColor)
Spacer() Spacer()
Text("\(count)") Text("\(column.count)")
.font(.caption) .font(.caption)
.fontWeight(.semibold) .fontWeight(.semibold)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 8) .padding(.horizontal, 8)
.padding(.vertical, 4) .padding(.vertical, 4)
.background(color) .background(columnColor)
.cornerRadius(12) .cornerRadius(12)
} }
if tasks.isEmpty { if column.tasks.isEmpty {
VStack(spacing: 8) { VStack(spacing: 8) {
Image(systemName: icon) Image(systemName: columnIcon)
.font(.system(size: 40)) .font(.system(size: 40))
.foregroundColor(color.opacity(0.3)) .foregroundColor(columnColor.opacity(0.3))
Text("No tasks") Text("No tasks")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.top, 40)
} else { } else {
ForEach(tasks, id: \.id) { task in ForEach(column.tasks, id: \.id) { task in
TaskCard( DynamicTaskCard(
task: task, task: task,
buttonTypes: column.buttonTypes,
onEdit: { onEditTask(task) }, onEdit: { onEditTask(task) },
onCancel: onCancelTask != nil ? { onCancelTask?(task) } : nil, onCancel: { onCancelTask(task.id) },
onUncancel: onUncancelTask != nil ? { onUncancelTask?(task) } : nil, onUncancel: { onUncancelTask(task.id) },
onMarkInProgress: onMarkInProgress != nil ? { onMarkInProgress?(task) } : nil, onMarkInProgress: { onMarkInProgress(task.id) },
onComplete: onCompleteTask != nil ? { onCompleteTask?(task) } : nil, onComplete: { onCompleteTask(task) },
onArchive: onArchiveTask != nil ? { onArchiveTask?(task) } : nil, onArchive: { onArchiveTask(task.id) },
onUnarchive: onUnarchiveTask != nil ? { onUnarchiveTask?(task) } : nil 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 to apply corner radius to specific corners
extension View { extension View {
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
@@ -381,7 +453,6 @@ struct RoundedCorner: Shape {
} }
} }
extension Array where Element == ResidenceWithTasks { extension Array where Element == ResidenceWithTasks {
/// Converts an array of ResidenceWithTasks into an array of Residence. /// Converts an array of ResidenceWithTasks into an array of Residence.
/// Adjust the mapping inside as needed to match your model initializers. /// 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, frequency: frequency.id,
intervalDays: nil, intervalDays: nil,
priority: priority.id, priority: priority.id,
status: KotlinInt(value: status.id),
dueDate: dueDate, dueDate: dueDate,
estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost
) )