Add task completion history feature and UI improvements

- Add CompletionHistorySheet for viewing task completion history (Android & iOS)
- Update TaskCard and DynamicTaskCard with completion history access
- Add getTaskCompletions API endpoint to TaskApi and APILayer
- Update models (CustomTask, Document, TaskCompletion, User) for Go API alignment
- Improve TaskKanbanView with completion history integration
- Update iOS TaskViewModel with completion history loading

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-28 12:01:56 -06:00
parent 60c824447d
commit 2baf5484e0
27 changed files with 1023 additions and 193 deletions

View File

@@ -51,6 +51,7 @@ data class TaskResponse(
@SerialName("is_cancelled") val isCancelled: Boolean = false,
@SerialName("is_archived") val isArchived: Boolean = false,
@SerialName("parent_task_id") val parentTaskId: Int? = null,
@SerialName("completion_count") val completionCount: Int = 0,
val completions: List<TaskCompletionResponse> = emptyList(),
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String
@@ -92,7 +93,8 @@ data class TaskCompletionResponse(
@SerialName("completed_at") val completedAt: String,
val notes: String = "",
@SerialName("actual_cost") val actualCost: Double? = null,
@SerialName("photo_url") val photoUrl: String = "",
val rating: Int? = null,
val images: List<TaskCompletionImage> = emptyList(),
@SerialName("created_at") val createdAt: String
) {
// Helper for backwards compatibility
@@ -101,12 +103,6 @@ data class TaskCompletionResponse(
// Backwards compatibility for UI that expects these fields
val task: Int get() = taskId
val images: List<TaskCompletionImage> get() = if (photoUrl.isNotEmpty()) {
listOf(TaskCompletionImage(id = 0, imageUrl = photoUrl))
} else {
emptyList()
}
val rating: Int? get() = null // Not in Go API
val contractor: Int? get() = null // Not in Go API - would need to be fetched separately
val contractorDetails: ContractorMinimal? get() = null // Not in Go API
}

View File

@@ -82,6 +82,8 @@ data class DocumentCreateRequest(
// Relationships
val residence: Int,
val contractor: Int? = null,
// Images
@SerialName("image_urls") val imageUrls: List<String>? = null,
// Metadata
val tags: String? = null,
val notes: String? = null,

View File

@@ -12,6 +12,7 @@ data class TaskCompletionCreateRequest(
@SerialName("completed_at") val completedAt: String? = null, // Defaults to now on server
val notes: String? = null,
@SerialName("actual_cost") val actualCost: Double? = null,
@SerialName("photo_url") val photoUrl: String? = null
val rating: Int? = null, // 1-5 star rating
@SerialName("image_urls") val imageUrls: List<String>? = null // Multiple image URLs
)

View File

@@ -17,7 +17,9 @@ data class User(
@SerialName("date_joined") val dateJoined: String,
@SerialName("last_login") val lastLogin: String? = null,
// Profile is included in CurrentUserResponse (/auth/me)
val profile: UserProfile? = null
val profile: UserProfile? = null,
// Verified is returned directly in LoginResponse, and also in profile for CurrentUserResponse
@SerialName("verified") private val _verified: Boolean = false
) {
// Computed property for display name
val displayName: String
@@ -28,9 +30,9 @@ data class User(
else -> username
}
// Check if user is verified (from profile)
// Check if user is verified - from direct field (login) OR from profile (currentUser)
val isVerified: Boolean
get() = profile?.verified ?: false
get() = _verified || (profile?.verified ?: false)
// Alias for backwards compatibility
val verified: Boolean

View File

@@ -574,6 +574,14 @@ object APILayer {
return result
}
/**
* Get all completions for a specific task
*/
suspend fun getTaskCompletions(taskId: Int): ApiResult<List<TaskCompletionResponse>> {
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
return taskApi.getTaskCompletions(token, taskId)
}
// ==================== Document Operations ====================
suspend fun getDocuments(

View File

@@ -115,7 +115,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun toggleFavorite(token: String, id: Int): ApiResult<Contractor> {
return try {
val response = client.post("$baseUrl/contractors/$id/toggle_favorite/") {
val response = client.post("$baseUrl/contractors/$id/toggle-favorite/") {
header("Authorization", "Token $token")
}

View File

@@ -174,4 +174,24 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun unarchiveTask(token: String, id: Int): ApiResult<TaskResponse> {
return patchTask(token, id, TaskPatchRequest(archived = false))
}
/**
* Get all completions for a specific task
*/
suspend fun getTaskCompletions(token: String, taskId: Int): ApiResult<List<TaskCompletionResponse>> {
return try {
val response = client.get("$baseUrl/tasks/$taskId/completions/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

@@ -111,7 +111,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
request.completedAt?.let { append("completed_at", it) }
request.actualCost?.let { append("actual_cost", it.toString()) }
request.notes?.let { append("notes", it) }
request.photoUrl?.let { append("photo_url", it) }
request.rating?.let { append("rating", it.toString()) }
// Add image files
images.forEachIndexed { index, imageBytes ->

View File

@@ -75,7 +75,9 @@ fun <T> ApiResultHandler(
}
}
is ApiResult.Success -> {
content(state.data)
Box(modifier = modifier.fillMaxSize()) {
content(state.data)
}
}
}

View File

@@ -274,7 +274,8 @@ fun CompleteTaskDialog(
completedAt = currentDate,
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
notes = notesWithContractor,
photoUrl = null // Images handled separately
rating = rating,
imageUrls = null // Images uploaded separately and URLs added by handler
),
selectedImages
)

View File

@@ -0,0 +1,402 @@
package com.example.mycrib.ui.components.task
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.example.mycrib.models.TaskCompletionResponse
import com.example.mycrib.models.TaskCompletion
import com.example.mycrib.network.ApiResult
import com.example.mycrib.network.APILayer
import kotlinx.coroutines.launch
/**
* Bottom sheet dialog that displays all completions for a task
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CompletionHistorySheet(
taskId: Int,
taskTitle: String,
onDismiss: () -> Unit
) {
val scope = rememberCoroutineScope()
var completions by remember { mutableStateOf<List<TaskCompletionResponse>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) }
// Load completions when the sheet opens
LaunchedEffect(taskId) {
isLoading = true
errorMessage = null
when (val result = APILayer.getTaskCompletions(taskId)) {
is ApiResult.Success -> {
completions = result.data
isLoading = false
}
is ApiResult.Error -> {
errorMessage = result.message
isLoading = false
}
else -> {
isLoading = false
}
}
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false),
containerColor = MaterialTheme.colorScheme.surface,
dragHandle = { BottomSheetDefaults.DragHandle() }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 32.dp)
) {
// Header
Text(
text = "Completion History",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
// Task title
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Task,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = taskTitle,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.weight(1f))
if (!isLoading && errorMessage == null) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "${completions.size} ${if (completions.size == 1) "completion" else "completions"}",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Content
when {
isLoading -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Loading completions...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
errorMessage != null -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Failed to load completions",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = errorMessage ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
TextButton(
onClick = {
scope.launch {
isLoading = true
errorMessage = null
when (val result = APILayer.getTaskCompletions(taskId)) {
is ApiResult.Success -> {
completions = result.data
isLoading = false
}
is ApiResult.Error -> {
errorMessage = result.message
isLoading = false
}
else -> {
isLoading = false
}
}
}
}
) {
Icon(Icons.Default.Refresh, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Retry")
}
}
}
}
completions.isEmpty() -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.CheckCircleOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No Completions Yet",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "This task has not been completed.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
else -> {
LazyColumn(
modifier = Modifier.heightIn(max = 400.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(completions) { completion ->
CompletionHistoryCard(completion = completion)
}
}
}
}
}
}
}
/**
* Card displaying a single completion in the history sheet
*/
@Composable
private fun CompletionHistoryCard(completion: TaskCompletionResponse) {
var showPhotoDialog by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// Header with date and completed by
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column {
Text(
text = formatCompletionDate(completion.completedAt),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
completion.completedBy?.let { user ->
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Completed by ${user.displayName}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// Cost
completion.actualCost?.let { cost ->
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.AttachMoney,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$$cost",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
}
}
// Notes
if (completion.notes.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Notes",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = completion.notes,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
// Rating
completion.rating?.let { rating ->
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Star,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$rating / 5",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
}
}
// Photo button
if (completion.images.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = { showPhotoDialog = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
shape = RoundedCornerShape(8.dp)
) {
Icon(
Icons.Default.PhotoLibrary,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (completion.images.size == 1) "View Photo" else "View Photos (${completion.images.size})",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
// Photo viewer dialog
if (showPhotoDialog && completion.images.isNotEmpty()) {
PhotoViewerDialog(
images = completion.images,
onDismiss = { showPhotoDialog = false }
)
}
}
private fun formatCompletionDate(dateString: String): String {
// Try to parse and format the date
return try {
val parts = dateString.split("T")
if (parts.isNotEmpty()) {
val dateParts = parts[0].split("-")
if (dateParts.size == 3) {
val year = dateParts[0]
val month = dateParts[1].toIntOrNull() ?: 1
val day = dateParts[2].toIntOrNull() ?: 1
val monthNames = listOf("", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
"${monthNames.getOrElse(month) { "Jan" }} $day, $year"
} else {
dateString
}
} else {
dateString
}
} catch (e: Exception) {
dateString
}
}

View File

@@ -32,14 +32,15 @@ fun TaskCard(
onUncancelClick: (() -> Unit)?,
onMarkInProgressClick: (() -> Unit)? = null,
onArchiveClick: (() -> Unit)? = null,
onUnarchiveClick: (() -> Unit)? = null
onUnarchiveClick: (() -> Unit)? = null,
onCompletionHistoryClick: (() -> Unit)? = null
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(
@@ -199,107 +200,111 @@ fun TaskCard(
}
}
// Show completions
if (task.completions.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Completions (${task.completions.size})",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.tertiary
)
}
task.completions.forEach { completion ->
Spacer(modifier = Modifier.height(12.dp))
CompletionCard(completion = completion)
}
}
// Actions dropdown menu based on buttonTypes array
if (buttonTypes.isNotEmpty()) {
// Actions row with completion count button and actions menu
if (buttonTypes.isNotEmpty() || task.completionCount > 0) {
var showActionsMenu by remember { mutableStateOf(false) }
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(16.dp))
Box {
Button(
onClick = { showActionsMenu = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
shape = RoundedCornerShape(8.dp)
) {
Icon(
Icons.Default.MoreVert,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Actions",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Actions dropdown menu based on buttonTypes array
if (buttonTypes.isNotEmpty()) {
Box(modifier = Modifier.weight(1f)) {
Button(
onClick = { showActionsMenu = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
shape = RoundedCornerShape(8.dp)
) {
Icon(
Icons.Default.MoreVert,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Actions",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
DropdownMenu(
expanded = showActionsMenu,
onDismissRequest = { showActionsMenu = false }
) {
// Primary actions
buttonTypes.filter { isPrimaryAction(it) }.forEach { buttonType ->
getActionMenuItem(
buttonType = buttonType,
task = task,
onMarkInProgressClick = onMarkInProgressClick,
onCompleteClick = onCompleteClick,
onEditClick = onEditClick,
onUncancelClick = onUncancelClick,
onUnarchiveClick = onUnarchiveClick,
onDismiss = { showActionsMenu = false }
)
}
// Secondary actions
if (buttonTypes.any { isSecondaryAction(it) }) {
HorizontalDivider()
buttonTypes.filter { isSecondaryAction(it) }.forEach { buttonType ->
getActionMenuItem(
buttonType = buttonType,
task = task,
onArchiveClick = onArchiveClick,
onDismiss = { showActionsMenu = false }
)
}
}
// Destructive actions
if (buttonTypes.any { isDestructiveAction(it) }) {
HorizontalDivider()
buttonTypes.filter { isDestructiveAction(it) }.forEach { buttonType ->
getActionMenuItem(
buttonType = buttonType,
task = task,
onCancelClick = onCancelClick,
onDismiss = { showActionsMenu = false }
)
}
}
}
}
}
DropdownMenu(
expanded = showActionsMenu,
onDismissRequest = { showActionsMenu = false }
) {
// Primary actions
buttonTypes.filter { isPrimaryAction(it) }.forEach { buttonType ->
getActionMenuItem(
buttonType = buttonType,
task = task,
onMarkInProgressClick = onMarkInProgressClick,
onCompleteClick = onCompleteClick,
onEditClick = onEditClick,
onUncancelClick = onUncancelClick,
onUnarchiveClick = onUnarchiveClick,
onDismiss = { showActionsMenu = false }
// Completion count button - shows when count > 0
if (task.completionCount > 0) {
Button(
onClick = { onCompletionHistoryClick?.invoke() },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
),
shape = RoundedCornerShape(8.dp)
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "${task.completionCount}",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold
)
}
// Secondary actions
if (buttonTypes.any { isSecondaryAction(it) }) {
HorizontalDivider()
buttonTypes.filter { isSecondaryAction(it) }.forEach { buttonType ->
getActionMenuItem(
buttonType = buttonType,
task = task,
onArchiveClick = onArchiveClick,
onDismiss = { showActionsMenu = false }
)
}
}
// Destructive actions
if (buttonTypes.any { isDestructiveAction(it) }) {
HorizontalDivider()
buttonTypes.filter { isDestructiveAction(it) }.forEach { buttonType ->
getActionMenuItem(
buttonType = buttonType,
task = task,
onCancelClick = onCancelClick,
onDismiss = { showActionsMenu = false }
)
}
}
}
}

View File

@@ -12,7 +12,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -194,6 +194,9 @@ private fun TaskColumn(
)
}
} else {
// State for completion history sheet
var selectedTaskForHistory by remember { mutableStateOf<TaskDetail?>(null) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
@@ -220,10 +223,22 @@ private fun TaskColumn(
} else null,
onUnarchiveClick = if (onUnarchiveTask != null) {
{ onUnarchiveTask(task) }
} else null,
onCompletionHistoryClick = if (task.completionCount > 0) {
{ selectedTaskForHistory = task }
} else null
)
}
}
// Completion history sheet
selectedTaskForHistory?.let { task ->
CompletionHistorySheet(
taskId = task.id,
taskTitle = task.title,
onDismiss = { selectedTaskForHistory = null }
)
}
}
}
}
@@ -242,7 +257,8 @@ fun DynamicTaskKanbanView(
onMarkInProgress: ((TaskDetail) -> Unit)?,
onArchiveTask: ((TaskDetail) -> Unit)?,
onUnarchiveTask: ((TaskDetail) -> Unit)?,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
bottomPadding: androidx.compose.ui.unit.Dp = 0.dp
) {
val pagerState = rememberPagerState(pageCount = { columns.size })
@@ -261,7 +277,8 @@ fun DynamicTaskKanbanView(
onUncancelTask = onUncancelTask,
onMarkInProgress = onMarkInProgress,
onArchiveTask = onArchiveTask,
onUnarchiveTask = onUnarchiveTask
onUnarchiveTask = onUnarchiveTask,
bottomPadding = bottomPadding
)
}
}
@@ -278,7 +295,8 @@ private fun DynamicTaskColumn(
onUncancelTask: ((TaskDetail) -> Unit)?,
onMarkInProgress: ((TaskDetail) -> Unit)?,
onArchiveTask: ((TaskDetail) -> Unit)?,
onUnarchiveTask: ((TaskDetail) -> Unit)?
onUnarchiveTask: ((TaskDetail) -> Unit)?,
bottomPadding: androidx.compose.ui.unit.Dp = 0.dp
) {
// Get icon from API response, with fallback
val columnIcon = getIconFromName(column.icons["android"] ?: "List")
@@ -286,18 +304,13 @@ private fun DynamicTaskColumn(
val columnColor = hexToColor(column.color)
Column(
modifier = Modifier
.fillMaxSize()
.background(
MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(12.dp)
)
modifier = Modifier.fillMaxSize()
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -356,10 +369,18 @@ private fun DynamicTaskColumn(
)
}
} else {
// State for completion history sheet
var selectedTaskForHistory by remember { mutableStateOf<TaskDetail?>(null) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 8.dp,
bottom = 16.dp + bottomPadding
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(column.tasks, key = { it.id }) { task ->
// Use existing TaskCard component with buttonTypes array
@@ -372,10 +393,22 @@ private fun DynamicTaskColumn(
onUncancelClick = onUncancelTask?.let { { it(task) } },
onMarkInProgressClick = onMarkInProgress?.let { { it(task) } },
onArchiveClick = onArchiveTask?.let { { it(task) } },
onUnarchiveClick = onUnarchiveTask?.let { { it(task) } }
onUnarchiveClick = onUnarchiveTask?.let { { it(task) } },
onCompletionHistoryClick = if (task.completionCount > 0) {
{ selectedTaskForHistory = task }
} else null
)
}
}
// Completion history sheet
selectedTaskForHistory?.let { task ->
CompletionHistorySheet(
taskId = task.id,
taskTitle = task.title,
onDismiss = { selectedTaskForHistory = null }
)
}
}
}
}

View File

@@ -31,7 +31,8 @@ fun AllTasksScreen(
onAddTask: () -> Unit = {},
viewModel: TaskViewModel = viewModel { TaskViewModel() },
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
bottomNavBarPadding: androidx.compose.ui.unit.Dp = 0.dp
) {
val tasksState by viewModel.tasksState.collectAsState()
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
@@ -126,8 +127,7 @@ fun AllTasksScreen(
if (hasNoTasks) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Column(
@@ -223,7 +223,8 @@ fun AllTasksScreen(
}
}
},
modifier = Modifier.padding(paddingValues)
modifier = Modifier,
bottomPadding = bottomNavBarPadding
)
}
}

View File

@@ -150,7 +150,8 @@ fun MainScreen(
Box(modifier = Modifier.fillMaxSize()) {
AllTasksScreen(
onNavigateToEditTask = onNavigateToEditTask,
onAddTask = onAddTask
onAddTask = onAddTask,
bottomNavBarPadding = paddingValues.calculateBottomPadding()
)
}
}

View File

@@ -378,7 +378,7 @@ fun ResidencesScreen(
StatItem(
icon = Icons.Default.Event,
value = "${response.summary.tasksDueNextMonth}",
label = "Due This Month"
label = "Next 30 Days"
)
}
}

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.example.mycrib.models.TaskColumnsResponse
import com.example.mycrib.models.CustomTask
import com.example.mycrib.models.TaskCreateRequest
import com.example.mycrib.models.TaskCompletionResponse
import com.example.mycrib.network.ApiResult
import com.example.mycrib.network.APILayer
import kotlinx.coroutines.flow.MutableStateFlow
@@ -22,6 +23,9 @@ class TaskViewModel : ViewModel() {
private val _taskAddNewCustomTaskState = MutableStateFlow<ApiResult<CustomTask>>(ApiResult.Idle)
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState
private val _taskCompletionsState = MutableStateFlow<ApiResult<List<TaskCompletionResponse>>>(ApiResult.Idle)
val taskCompletionsState: StateFlow<ApiResult<List<TaskCompletionResponse>>> = _taskCompletionsState
fun loadTasks(forceRefresh: Boolean = false) {
println("TaskViewModel: loadTasks called")
viewModelScope.launch {
@@ -152,4 +156,21 @@ class TaskViewModel : ViewModel() {
}
}
}
/**
* Load completions for a specific task
*/
fun loadTaskCompletions(taskId: Int) {
viewModelScope.launch {
_taskCompletionsState.value = ApiResult.Loading
_taskCompletionsState.value = APILayer.getTaskCompletions(taskId)
}
}
/**
* Reset task completions state
*/
fun resetTaskCompletionsState() {
_taskCompletionsState.value = ApiResult.Idle
}
}