diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt index d5c7866..49df061 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt @@ -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 = 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 = 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 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 } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Document.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Document.kt index 31f2ae3..633a5a1 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Document.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Document.kt @@ -82,6 +82,8 @@ data class DocumentCreateRequest( // Relationships val residence: Int, val contractor: Int? = null, + // Images + @SerialName("image_urls") val imageUrls: List? = null, // Metadata val tags: String? = null, val notes: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/TaskCompletion.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/TaskCompletion.kt index fab89ac..4f4577d 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/TaskCompletion.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/TaskCompletion.kt @@ -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? = null // Multiple image URLs ) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/User.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/User.kt index e60da92..60b2922 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/User.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/User.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/APILayer.kt index 2202931..85cf033 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/APILayer.kt @@ -574,6 +574,14 @@ object APILayer { return result } + /** + * Get all completions for a specific task + */ + suspend fun getTaskCompletions(taskId: Int): ApiResult> { + val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + return taskApi.getTaskCompletions(token, taskId) + } + // ==================== Document Operations ==================== suspend fun getDocuments( diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ContractorApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ContractorApi.kt index 08eeada..172dfec 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ContractorApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ContractorApi.kt @@ -115,7 +115,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun toggleFavorite(token: String, id: Int): ApiResult { return try { - val response = client.post("$baseUrl/contractors/$id/toggle_favorite/") { + val response = client.post("$baseUrl/contractors/$id/toggle-favorite/") { header("Authorization", "Token $token") } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt index 459ffb6..ff07c04 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt @@ -174,4 +174,24 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun unarchiveTask(token: String, id: Int): ApiResult { return patchTask(token, id, TaskPatchRequest(archived = false)) } + + /** + * Get all completions for a specific task + */ + suspend fun getTaskCompletions(token: String, taskId: Int): ApiResult> { + 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") + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskCompletionApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskCompletionApi.kt index 8ccec13..ace2c94 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskCompletionApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskCompletionApi.kt @@ -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 -> diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/ApiResultHandler.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/ApiResultHandler.kt index 58d30fa..a606b36 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/ApiResultHandler.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/ApiResultHandler.kt @@ -75,7 +75,9 @@ fun ApiResultHandler( } } is ApiResult.Success -> { - content(state.data) + Box(modifier = modifier.fillMaxSize()) { + content(state.data) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/CompleteTaskDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/CompleteTaskDialog.kt index 337d432..2514b5f 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/CompleteTaskDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/CompleteTaskDialog.kt @@ -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 ) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/CompletionHistorySheet.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/CompletionHistorySheet.kt new file mode 100644 index 0000000..7261f5f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/CompletionHistorySheet.kt @@ -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>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(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 + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt index d272bda..490cbbd 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt @@ -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 } - ) - } } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskKanbanView.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskKanbanView.kt index 919895b..8123c8c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskKanbanView.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskKanbanView.kt @@ -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(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(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 } + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AllTasksScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AllTasksScreen.kt index c32b384..7a734c8 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AllTasksScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AllTasksScreen.kt @@ -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 ) } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt index 15499a2..83a2801 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt @@ -150,7 +150,8 @@ fun MainScreen( Box(modifier = Modifier.fillMaxSize()) { AllTasksScreen( onNavigateToEditTask = onNavigateToEditTask, - onAddTask = onAddTask + onAddTask = onAddTask, + bottomNavBarPadding = paddingValues.calculateBottomPadding() ) } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt index 726374b..daf8bfa 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt @@ -378,7 +378,7 @@ fun ResidencesScreen( StatItem( icon = Icons.Default.Event, value = "${response.summary.tasksDueNextMonth}", - label = "Due This Month" + label = "Next 30 Days" ) } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt index a122ad7..579a715 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt @@ -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.Idle) val taskAddNewCustomTaskState: StateFlow> = _taskAddNewCustomTaskState + private val _taskCompletionsState = MutableStateFlow>>(ApiResult.Idle) + val taskCompletionsState: StateFlow>> = _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 + } } diff --git a/iosApp/iosApp/Contractor/ContractorsListView.swift b/iosApp/iosApp/Contractor/ContractorsListView.swift index 50567ad..ff622f5 100644 --- a/iosApp/iosApp/Contractor/ContractorsListView.swift +++ b/iosApp/iosApp/Contractor/ContractorsListView.swift @@ -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) } } diff --git a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift index be385b4..d9a4b0e 100644 --- a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift +++ b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift @@ -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) } } } diff --git a/iosApp/iosApp/Subviews/Residence/SummaryCard.swift b/iosApp/iosApp/Subviews/Residence/SummaryCard.swift index 3820766..2e5c7e1 100644 --- a/iosApp/iosApp/Subviews/Residence/SummaryCard.swift +++ b/iosApp/iosApp/Subviews/Residence/SummaryCard.swift @@ -41,7 +41,7 @@ struct SummaryCard: View { SummaryStatView( icon: "calendar.badge.clock", value: "\(summary.tasksDueNextMonth)", - label: "Due This Month" + label: "Next 30 Days" ) } } diff --git a/iosApp/iosApp/Subviews/Task/DynamicTaskCard.swift b/iosApp/iosApp/Subviews/Task/DynamicTaskCard.swift index 9d6c71a..896363a 100644 --- a/iosApp/iosApp/Subviews/Task/DynamicTaskCard.swift +++ b/iosApp/iosApp/Subviews/Task/DynamicTaskCard.swift @@ -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 { diff --git a/iosApp/iosApp/Subviews/Task/TaskCard.swift b/iosApp/iosApp/Subviews/Task/TaskCard.swift index 4de291a..2843a63 100644 --- a/iosApp/iosApp/Subviews/Task/TaskCard.swift +++ b/iosApp/iosApp/Subviews/Task/TaskCard.swift @@ -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" diff --git a/iosApp/iosApp/Subviews/Task/TasksSection.swift b/iosApp/iosApp/Subviews/Task/TasksSection.swift index 86e0395..8c89fe9 100644 --- a/iosApp/iosApp/Subviews/Task/TasksSection.swift +++ b/iosApp/iosApp/Subviews/Task/TasksSection.swift @@ -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" diff --git a/iosApp/iosApp/Task/CompleteTaskView.swift b/iosApp/iosApp/Task/CompleteTaskView.swift index faa3d51..16d34b5 100644 --- a/iosApp/iosApp/Task/CompleteTaskView.swift +++ b/iosApp/iosApp/Task/CompleteTaskView.swift @@ -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 diff --git a/iosApp/iosApp/Task/CompletionHistorySheet.swift b/iosApp/iosApp/Task/CompletionHistorySheet.swift new file mode 100644 index 0000000..cb5e56f --- /dev/null +++ b/iosApp/iosApp/Task/CompletionHistorySheet.swift @@ -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) + ) +} diff --git a/iosApp/iosApp/Task/TaskFormView.swift b/iosApp/iosApp/Task/TaskFormView.swift index 0cd4c80..fc9daa7 100644 --- a/iosApp/iosApp/Task/TaskFormView.swift +++ b/iosApp/iosApp/Task/TaskFormView.swift @@ -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) diff --git a/iosApp/iosApp/Task/TaskViewModel.swift b/iosApp/iosApp/Task/TaskViewModel.swift index 92698ee..9017ee4 100644 --- a/iosApp/iosApp/Task/TaskViewModel.swift +++ b/iosApp/iosApp/Task/TaskViewModel.swift @@ -7,6 +7,9 @@ class TaskViewModel: ObservableObject { // MARK: - Published Properties @Published var actionState: ActionState = .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 { + 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() + } }