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

View File

@@ -28,7 +28,7 @@ struct ContractorsListView: View {
// Check if upgrade screen should be shown (disables add button)
private var shouldShowUpgrade: Bool {
subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors")
subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors")
}
var body: some View {
@@ -137,9 +137,7 @@ struct ContractorsListView: View {
// Add Button (disabled when showing upgrade screen)
Button(action: {
// Check LIVE contractor count before adding
let currentCount = viewModel.contractors.count
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "contractors") {
if subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors") {
showingUpgradePrompt = true
} else {
showingAddSheet = true
@@ -147,9 +145,8 @@ struct ContractorsListView: View {
}) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundColor(shouldShowUpgrade ? Color.appTextSecondary.opacity(0.5) : Color.appPrimary)
.foregroundColor(Color.appPrimary)
}
.disabled(shouldShowUpgrade)
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton)
}
}

View File

@@ -171,9 +171,8 @@ struct DocumentsWarrantiesView: View {
}) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundColor(shouldShowUpgrade ? Color.appTextSecondary.opacity(0.5) : Color.appPrimary)
.foregroundColor(Color.appPrimary)
}
.disabled(shouldShowUpgrade)
}
}
}

View File

@@ -41,7 +41,7 @@ struct SummaryCard: View {
SummaryStatView(
icon: "calendar.badge.clock",
value: "\(summary.tasksDueNextMonth)",
label: "Due This Month"
label: "Next 30 Days"
)
}
}

View File

@@ -13,7 +13,7 @@ struct DynamicTaskCard: View {
let onArchive: () -> Void
let onUnarchive: () -> Void
@State private var isCompletionsExpanded = false
@State private var showCompletionHistory = false
var body: some View {
let _ = print("📋 DynamicTaskCard - Task: \(task.title), ButtonTypes: \(buttonTypes)")
@@ -56,69 +56,75 @@ struct DynamicTaskCard: View {
}
}
if task.completions.count > 0 {
// Actions row with completion count button and actions menu
if !buttonTypes.isEmpty || task.completionCount > 0 {
Divider()
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "checkmark.circle.fill")
HStack(spacing: 12) {
// Actions menu
if !buttonTypes.isEmpty {
Menu {
menuContent
} label: {
HStack {
Image(systemName: "ellipsis.circle.fill")
.font(.title3)
Text("Actions")
.fontWeight(.medium)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.appPrimary.opacity(0.1))
.foregroundColor(Color.appPrimary)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.appPrimary, lineWidth: 2)
)
}
.zIndex(10)
.menuOrder(.fixed)
}
// Completion count button - shows when count > 0
if task.completionCount > 0 {
Button(action: {
showCompletionHistory = true
}) {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.font(.title3)
Text("\(task.completionCount)")
.fontWeight(.bold)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.appAccent.opacity(0.1))
.foregroundColor(Color.appAccent)
Text("Completions (\(task.completions.count))")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary)
Spacer()
Image(systemName: isCompletionsExpanded ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
isCompletionsExpanded.toggle()
}
}
if isCompletionsExpanded {
ForEach(task.completions, id: \.id) { completion in
CompletionCardView(completion: completion)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.appAccent, lineWidth: 2)
)
}
}
}
}
// Actions menu
if !buttonTypes.isEmpty {
Divider()
Menu {
menuContent
} label: {
HStack {
Image(systemName: "ellipsis.circle.fill")
.font(.title3)
Text("Actions")
.fontWeight(.medium)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.appPrimary.opacity(0.1))
.foregroundColor(Color.appPrimary)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.appPrimary, lineWidth: 2)
)
}
.zIndex(10)
.menuOrder(.fixed)
}
}
.padding(16)
.background(Color.appBackgroundSecondary)
.cornerRadius(12)
.shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2)
.simultaneousGesture(TapGesture(), including: .subviews)
.sheet(isPresented: $showCompletionHistory) {
CompletionHistorySheet(
taskTitle: task.title,
taskId: task.id,
isPresented: $showCompletionHistory
)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
}
private func formatDate(_ dateString: String) -> String {

View File

@@ -279,6 +279,7 @@ struct TaskCard: View {
isCancelled: false,
isArchived: false,
parentTaskId: nil,
completionCount: 0,
completions: [],
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z"

View File

@@ -103,6 +103,7 @@ struct TasksSection: View {
isCancelled: false,
isArchived: false,
parentTaskId: nil,
completionCount: 0,
completions: [],
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z"
@@ -141,6 +142,7 @@ struct TasksSection: View {
isCancelled: false,
isArchived: false,
parentTaskId: nil,
completionCount: 3,
completions: [],
createdAt: "2024-10-01T00:00:00Z",
updatedAt: "2024-11-05T00:00:00Z"

View File

@@ -311,7 +311,8 @@ struct CompleteTaskView: View {
completedAt: nil,
notes: notes.isEmpty ? nil : notes,
actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0),
photoUrl: nil
rating: KotlinInt(int: Int32(rating)),
imageUrls: nil // Images uploaded separately and URLs added by handler
)
// Use TaskCompletionViewModel to create completion

View File

@@ -0,0 +1,288 @@
import SwiftUI
import ComposeApp
/// Bottom sheet view that displays all completions for a task
struct CompletionHistorySheet: View {
let taskTitle: String
let taskId: Int32
@Binding var isPresented: Bool
@StateObject private var viewModel = TaskViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoadingCompletions {
loadingView
} else if let error = viewModel.completionsError {
errorView(error)
} else if viewModel.completions.isEmpty {
emptyView
} else {
completionsList
}
}
.navigationTitle("Completion History")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
isPresented = false
}
}
}
.background(Color.appBackgroundPrimary)
}
.onAppear {
viewModel.loadCompletions(taskId: taskId)
}
.onDisappear {
viewModel.resetCompletionsState()
}
}
// MARK: - Subviews
private var loadingView: some View {
VStack(spacing: AppSpacing.md) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Color.appPrimary))
.scaleEffect(1.5)
Text("Loading completions...")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func errorView(_ error: String) -> some View {
VStack(spacing: AppSpacing.md) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundColor(Color.appError)
Text("Failed to load completions")
.font(.headline)
.foregroundColor(Color.appTextPrimary)
Text(error)
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
Button(action: {
viewModel.loadCompletions(taskId: taskId)
}) {
Label("Retry", systemImage: "arrow.clockwise")
.foregroundColor(Color.appPrimary)
}
.padding(.top, AppSpacing.sm)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var emptyView: some View {
VStack(spacing: AppSpacing.md) {
Image(systemName: "checkmark.circle")
.font(.system(size: 48))
.foregroundColor(Color.appTextSecondary.opacity(0.5))
Text("No Completions Yet")
.font(.headline)
.foregroundColor(Color.appTextPrimary)
Text("This task has not been completed.")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var completionsList: some View {
ScrollView {
VStack(spacing: AppSpacing.sm) {
// Task title header
HStack {
Image(systemName: "doc.text")
.foregroundColor(Color.appPrimary)
Text(taskTitle)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary)
Spacer()
Text("\(viewModel.completions.count) \(viewModel.completions.count == 1 ? "completion" : "completions")")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
.padding()
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.md)
// Completions list
ForEach(viewModel.completions, id: \.id) { completion in
CompletionHistoryCard(completion: completion)
}
}
.padding()
}
}
}
/// Card displaying a single completion in the history sheet
struct CompletionHistoryCard: View {
let completion: TaskCompletionResponse
@State private var showPhotoSheet = false
var body: some View {
VStack(alignment: .leading, spacing: AppSpacing.sm) {
// Header with date and completed by
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(formatDate(completion.completionDate))
.font(.headline)
.foregroundColor(Color.appTextPrimary)
if let completedBy = completion.completedByName, !completedBy.isEmpty {
HStack(spacing: 4) {
Image(systemName: "person.fill")
.font(.caption2)
Text("Completed by \(completedBy)")
.font(.caption)
}
.foregroundColor(Color.appTextSecondary)
}
}
Spacer()
// Rating badge
if let rating = completion.rating {
HStack(spacing: 2) {
Image(systemName: "star.fill")
.font(.caption)
Text("\(rating)")
.font(.subheadline)
.fontWeight(.bold)
}
.foregroundColor(Color.appAccent)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.appAccent.opacity(0.1))
.cornerRadius(AppRadius.sm)
}
}
Divider()
// Contractor info
if let contractor = completion.contractorDetails {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "wrench.and.screwdriver.fill")
.foregroundColor(Color.appPrimary)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(contractor.name)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(Color.appTextPrimary)
if let company = contractor.company {
Text(company)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
}
}
}
// Cost
if let cost = completion.actualCost {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "dollarsign.circle.fill")
.foregroundColor(Color.appPrimary)
.frame(width: 24)
Text("$\(cost)")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(Color.appPrimary)
}
}
// Notes
if !completion.notes.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Notes")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(Color.appTextSecondary)
Text(completion.notes)
.font(.subheadline)
.foregroundColor(Color.appTextPrimary)
}
.padding(.top, 4)
}
// Photos button
if !completion.images.isEmpty {
Button(action: {
showPhotoSheet = true
}) {
HStack {
Image(systemName: "photo.on.rectangle.angled")
.font(.subheadline)
Text("View Photos (\(completion.images.count))")
.font(.subheadline)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.appPrimary.opacity(0.1))
.foregroundColor(Color.appPrimary)
.cornerRadius(AppRadius.sm)
}
.padding(.top, 4)
}
}
.padding()
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.lg)
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
.sheet(isPresented: $showPhotoSheet) {
PhotoViewerSheet(images: completion.images)
}
}
private func formatDate(_ dateString: String) -> String {
let formatters = [
"yyyy-MM-dd'T'HH:mm:ssZ",
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd"
]
let inputFormatter = DateFormatter()
let outputFormatter = DateFormatter()
outputFormatter.dateStyle = .long
outputFormatter.timeStyle = .short
for format in formatters {
inputFormatter.dateFormat = format
if let date = inputFormatter.date(from: dateString) {
return outputFormatter.string(from: date)
}
}
return dateString
}
}
#Preview {
CompletionHistorySheet(
taskTitle: "Change HVAC Filter",
taskId: 1,
isPresented: .constant(true)
)
}

View File

@@ -61,7 +61,7 @@ struct TaskFormView: View {
// Initialize fields from existing task or with defaults
if let task = existingTask {
_title = State(initialValue: task.title)
_description = State(initialValue: task.description ?? "")
_description = State(initialValue: task.description_ ?? "")
_selectedCategory = State(initialValue: task.category)
_selectedFrequency = State(initialValue: task.frequency)
_selectedPriority = State(initialValue: task.priority)

View File

@@ -7,6 +7,9 @@ class TaskViewModel: ObservableObject {
// MARK: - Published Properties
@Published var actionState: ActionState<TaskActionType> = .idle
@Published var errorMessage: String?
@Published var completions: [TaskCompletionResponse] = []
@Published var isLoadingCompletions: Bool = false
@Published var completionsError: String?
// MARK: - Computed Properties (Backward Compatibility)
@@ -178,4 +181,42 @@ class TaskViewModel: ObservableObject {
actionState = .idle
errorMessage = nil
}
// MARK: - Task Completions
func loadCompletions(taskId: Int32) {
isLoadingCompletions = true
completionsError = nil
sharedViewModel.loadTaskCompletions(taskId: taskId)
Task {
for await state in sharedViewModel.taskCompletionsState {
if let success = state as? ApiResultSuccess<NSArray> {
await MainActor.run {
self.completions = (success.data as? [TaskCompletionResponse]) ?? []
self.isLoadingCompletions = false
}
break
} else if let error = state as? ApiResultError {
await MainActor.run {
self.completionsError = error.message
self.isLoadingCompletions = false
}
break
} else if state is ApiResultLoading {
await MainActor.run {
self.isLoadingCompletions = true
}
}
}
}
}
func resetCompletionsState() {
completions = []
completionsError = nil
isLoadingCompletions = false
sharedViewModel.resetTaskCompletionsState()
}
}