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:
@@ -51,6 +51,7 @@ data class TaskResponse(
|
|||||||
@SerialName("is_cancelled") val isCancelled: Boolean = false,
|
@SerialName("is_cancelled") val isCancelled: Boolean = false,
|
||||||
@SerialName("is_archived") val isArchived: Boolean = false,
|
@SerialName("is_archived") val isArchived: Boolean = false,
|
||||||
@SerialName("parent_task_id") val parentTaskId: Int? = null,
|
@SerialName("parent_task_id") val parentTaskId: Int? = null,
|
||||||
|
@SerialName("completion_count") val completionCount: Int = 0,
|
||||||
val completions: List<TaskCompletionResponse> = emptyList(),
|
val completions: List<TaskCompletionResponse> = emptyList(),
|
||||||
@SerialName("created_at") val createdAt: String,
|
@SerialName("created_at") val createdAt: String,
|
||||||
@SerialName("updated_at") val updatedAt: String
|
@SerialName("updated_at") val updatedAt: String
|
||||||
@@ -92,7 +93,8 @@ data class TaskCompletionResponse(
|
|||||||
@SerialName("completed_at") val completedAt: String,
|
@SerialName("completed_at") val completedAt: String,
|
||||||
val notes: String = "",
|
val notes: String = "",
|
||||||
@SerialName("actual_cost") val actualCost: Double? = null,
|
@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
|
@SerialName("created_at") val createdAt: String
|
||||||
) {
|
) {
|
||||||
// Helper for backwards compatibility
|
// Helper for backwards compatibility
|
||||||
@@ -101,12 +103,6 @@ data class TaskCompletionResponse(
|
|||||||
|
|
||||||
// Backwards compatibility for UI that expects these fields
|
// Backwards compatibility for UI that expects these fields
|
||||||
val task: Int get() = taskId
|
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 contractor: Int? get() = null // Not in Go API - would need to be fetched separately
|
||||||
val contractorDetails: ContractorMinimal? get() = null // Not in Go API
|
val contractorDetails: ContractorMinimal? get() = null // Not in Go API
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ data class DocumentCreateRequest(
|
|||||||
// Relationships
|
// Relationships
|
||||||
val residence: Int,
|
val residence: Int,
|
||||||
val contractor: Int? = null,
|
val contractor: Int? = null,
|
||||||
|
// Images
|
||||||
|
@SerialName("image_urls") val imageUrls: List<String>? = null,
|
||||||
// Metadata
|
// Metadata
|
||||||
val tags: String? = null,
|
val tags: String? = null,
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ data class TaskCompletionCreateRequest(
|
|||||||
@SerialName("completed_at") val completedAt: String? = null, // Defaults to now on server
|
@SerialName("completed_at") val completedAt: String? = null, // Defaults to now on server
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
@SerialName("actual_cost") val actualCost: Double? = 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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ data class User(
|
|||||||
@SerialName("date_joined") val dateJoined: String,
|
@SerialName("date_joined") val dateJoined: String,
|
||||||
@SerialName("last_login") val lastLogin: String? = null,
|
@SerialName("last_login") val lastLogin: String? = null,
|
||||||
// Profile is included in CurrentUserResponse (/auth/me)
|
// 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
|
// Computed property for display name
|
||||||
val displayName: String
|
val displayName: String
|
||||||
@@ -28,9 +30,9 @@ data class User(
|
|||||||
else -> username
|
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
|
val isVerified: Boolean
|
||||||
get() = profile?.verified ?: false
|
get() = _verified || (profile?.verified ?: false)
|
||||||
|
|
||||||
// Alias for backwards compatibility
|
// Alias for backwards compatibility
|
||||||
val verified: Boolean
|
val verified: Boolean
|
||||||
|
|||||||
@@ -574,6 +574,14 @@ object APILayer {
|
|||||||
return result
|
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 ====================
|
// ==================== Document Operations ====================
|
||||||
|
|
||||||
suspend fun getDocuments(
|
suspend fun getDocuments(
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
|
|
||||||
suspend fun toggleFavorite(token: String, id: Int): ApiResult<Contractor> {
|
suspend fun toggleFavorite(token: String, id: Int): ApiResult<Contractor> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/contractors/$id/toggle_favorite/") {
|
val response = client.post("$baseUrl/contractors/$id/toggle-favorite/") {
|
||||||
header("Authorization", "Token $token")
|
header("Authorization", "Token $token")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,4 +174,24 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun unarchiveTask(token: String, id: Int): ApiResult<TaskResponse> {
|
suspend fun unarchiveTask(token: String, id: Int): ApiResult<TaskResponse> {
|
||||||
return patchTask(token, id, TaskPatchRequest(archived = false))
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
request.completedAt?.let { append("completed_at", it) }
|
request.completedAt?.let { append("completed_at", it) }
|
||||||
request.actualCost?.let { append("actual_cost", it.toString()) }
|
request.actualCost?.let { append("actual_cost", it.toString()) }
|
||||||
request.notes?.let { append("notes", it) }
|
request.notes?.let { append("notes", it) }
|
||||||
request.photoUrl?.let { append("photo_url", it) }
|
request.rating?.let { append("rating", it.toString()) }
|
||||||
|
|
||||||
// Add image files
|
// Add image files
|
||||||
images.forEachIndexed { index, imageBytes ->
|
images.forEachIndexed { index, imageBytes ->
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ fun <T> ApiResultHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
content(state.data)
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
|
content(state.data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -274,7 +274,8 @@ fun CompleteTaskDialog(
|
|||||||
completedAt = currentDate,
|
completedAt = currentDate,
|
||||||
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
|
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
|
||||||
notes = notesWithContractor,
|
notes = notesWithContractor,
|
||||||
photoUrl = null // Images handled separately
|
rating = rating,
|
||||||
|
imageUrls = null // Images uploaded separately and URLs added by handler
|
||||||
),
|
),
|
||||||
selectedImages
|
selectedImages
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,14 +32,15 @@ fun TaskCard(
|
|||||||
onUncancelClick: (() -> Unit)?,
|
onUncancelClick: (() -> Unit)?,
|
||||||
onMarkInProgressClick: (() -> Unit)? = null,
|
onMarkInProgressClick: (() -> Unit)? = null,
|
||||||
onArchiveClick: (() -> Unit)? = null,
|
onArchiveClick: (() -> Unit)? = null,
|
||||||
onUnarchiveClick: (() -> Unit)? = null
|
onUnarchiveClick: (() -> Unit)? = null,
|
||||||
|
onCompletionHistoryClick: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = RoundedCornerShape(12.dp),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -199,107 +200,111 @@ fun TaskCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show completions
|
// Actions row with completion count button and actions menu
|
||||||
if (task.completions.isNotEmpty()) {
|
if (buttonTypes.isNotEmpty() || task.completionCount > 0) {
|
||||||
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()) {
|
|
||||||
var showActionsMenu by remember { mutableStateOf(false) }
|
var showActionsMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Box {
|
Row(
|
||||||
Button(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onClick = { showActionsMenu = true },
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
) {
|
||||||
colors = ButtonDefaults.buttonColors(
|
// Actions dropdown menu based on buttonTypes array
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
if (buttonTypes.isNotEmpty()) {
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
),
|
Button(
|
||||||
shape = RoundedCornerShape(8.dp)
|
onClick = { showActionsMenu = true },
|
||||||
) {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Icon(
|
colors = ButtonDefaults.buttonColors(
|
||||||
Icons.Default.MoreVert,
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
contentDescription = null,
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
modifier = Modifier.size(18.dp)
|
),
|
||||||
)
|
shape = RoundedCornerShape(8.dp)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
) {
|
||||||
Text(
|
Icon(
|
||||||
text = "Actions",
|
Icons.Default.MoreVert,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
contentDescription = null,
|
||||||
fontWeight = FontWeight.SemiBold
|
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(
|
// Completion count button - shows when count > 0
|
||||||
expanded = showActionsMenu,
|
if (task.completionCount > 0) {
|
||||||
onDismissRequest = { showActionsMenu = false }
|
Button(
|
||||||
) {
|
onClick = { onCompletionHistoryClick?.invoke() },
|
||||||
// Primary actions
|
colors = ButtonDefaults.buttonColors(
|
||||||
buttonTypes.filter { isPrimaryAction(it) }.forEach { buttonType ->
|
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
getActionMenuItem(
|
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
|
||||||
buttonType = buttonType,
|
),
|
||||||
task = task,
|
shape = RoundedCornerShape(8.dp)
|
||||||
onMarkInProgressClick = onMarkInProgressClick,
|
) {
|
||||||
onCompleteClick = onCompleteClick,
|
Icon(
|
||||||
onEditClick = onEditClick,
|
Icons.Default.CheckCircle,
|
||||||
onUncancelClick = onUncancelClick,
|
contentDescription = null,
|
||||||
onUnarchiveClick = onUnarchiveClick,
|
modifier = Modifier.size(18.dp)
|
||||||
onDismiss = { showActionsMenu = false }
|
)
|
||||||
|
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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -194,6 +194,9 @@ private fun TaskColumn(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// State for completion history sheet
|
||||||
|
var selectedTaskForHistory by remember { mutableStateOf<TaskDetail?>(null) }
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
@@ -220,10 +223,22 @@ private fun TaskColumn(
|
|||||||
} else null,
|
} else null,
|
||||||
onUnarchiveClick = if (onUnarchiveTask != null) {
|
onUnarchiveClick = if (onUnarchiveTask != null) {
|
||||||
{ onUnarchiveTask(task) }
|
{ onUnarchiveTask(task) }
|
||||||
|
} else null,
|
||||||
|
onCompletionHistoryClick = if (task.completionCount > 0) {
|
||||||
|
{ selectedTaskForHistory = task }
|
||||||
} else null
|
} 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)?,
|
onMarkInProgress: ((TaskDetail) -> Unit)?,
|
||||||
onArchiveTask: ((TaskDetail) -> Unit)?,
|
onArchiveTask: ((TaskDetail) -> Unit)?,
|
||||||
onUnarchiveTask: ((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 })
|
val pagerState = rememberPagerState(pageCount = { columns.size })
|
||||||
|
|
||||||
@@ -261,7 +277,8 @@ fun DynamicTaskKanbanView(
|
|||||||
onUncancelTask = onUncancelTask,
|
onUncancelTask = onUncancelTask,
|
||||||
onMarkInProgress = onMarkInProgress,
|
onMarkInProgress = onMarkInProgress,
|
||||||
onArchiveTask = onArchiveTask,
|
onArchiveTask = onArchiveTask,
|
||||||
onUnarchiveTask = onUnarchiveTask
|
onUnarchiveTask = onUnarchiveTask,
|
||||||
|
bottomPadding = bottomPadding
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,7 +295,8 @@ private fun DynamicTaskColumn(
|
|||||||
onUncancelTask: ((TaskDetail) -> Unit)?,
|
onUncancelTask: ((TaskDetail) -> Unit)?,
|
||||||
onMarkInProgress: ((TaskDetail) -> Unit)?,
|
onMarkInProgress: ((TaskDetail) -> Unit)?,
|
||||||
onArchiveTask: ((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
|
// Get icon from API response, with fallback
|
||||||
val columnIcon = getIconFromName(column.icons["android"] ?: "List")
|
val columnIcon = getIconFromName(column.icons["android"] ?: "List")
|
||||||
@@ -286,18 +304,13 @@ private fun DynamicTaskColumn(
|
|||||||
val columnColor = hexToColor(column.color)
|
val columnColor = hexToColor(column.color)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize()
|
||||||
.fillMaxSize()
|
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme.surface,
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
// Header
|
// Header
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@@ -356,10 +369,18 @@ private fun DynamicTaskColumn(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// State for completion history sheet
|
||||||
|
var selectedTaskForHistory by remember { mutableStateOf<TaskDetail?>(null) }
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
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 ->
|
items(column.tasks, key = { it.id }) { task ->
|
||||||
// Use existing TaskCard component with buttonTypes array
|
// Use existing TaskCard component with buttonTypes array
|
||||||
@@ -372,10 +393,22 @@ private fun DynamicTaskColumn(
|
|||||||
onUncancelClick = onUncancelTask?.let { { it(task) } },
|
onUncancelClick = onUncancelTask?.let { { it(task) } },
|
||||||
onMarkInProgressClick = onMarkInProgress?.let { { it(task) } },
|
onMarkInProgressClick = onMarkInProgress?.let { { it(task) } },
|
||||||
onArchiveClick = onArchiveTask?.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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ fun AllTasksScreen(
|
|||||||
onAddTask: () -> Unit = {},
|
onAddTask: () -> Unit = {},
|
||||||
viewModel: TaskViewModel = viewModel { TaskViewModel() },
|
viewModel: TaskViewModel = viewModel { TaskViewModel() },
|
||||||
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
|
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 tasksState by viewModel.tasksState.collectAsState()
|
||||||
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
|
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
|
||||||
@@ -126,8 +127,7 @@ fun AllTasksScreen(
|
|||||||
if (hasNoTasks) {
|
if (hasNoTasks) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize(),
|
||||||
.padding(paddingValues),
|
|
||||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -223,7 +223,8 @@ fun AllTasksScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.padding(paddingValues)
|
modifier = Modifier,
|
||||||
|
bottomPadding = bottomNavBarPadding
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,7 +150,8 @@ fun MainScreen(
|
|||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AllTasksScreen(
|
AllTasksScreen(
|
||||||
onNavigateToEditTask = onNavigateToEditTask,
|
onNavigateToEditTask = onNavigateToEditTask,
|
||||||
onAddTask = onAddTask
|
onAddTask = onAddTask,
|
||||||
|
bottomNavBarPadding = paddingValues.calculateBottomPadding()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -378,7 +378,7 @@ fun ResidencesScreen(
|
|||||||
StatItem(
|
StatItem(
|
||||||
icon = Icons.Default.Event,
|
icon = Icons.Default.Event,
|
||||||
value = "${response.summary.tasksDueNextMonth}",
|
value = "${response.summary.tasksDueNextMonth}",
|
||||||
label = "Due This Month"
|
label = "Next 30 Days"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.example.mycrib.models.TaskColumnsResponse
|
import com.example.mycrib.models.TaskColumnsResponse
|
||||||
import com.example.mycrib.models.CustomTask
|
import com.example.mycrib.models.CustomTask
|
||||||
import com.example.mycrib.models.TaskCreateRequest
|
import com.example.mycrib.models.TaskCreateRequest
|
||||||
|
import com.example.mycrib.models.TaskCompletionResponse
|
||||||
import com.example.mycrib.network.ApiResult
|
import com.example.mycrib.network.ApiResult
|
||||||
import com.example.mycrib.network.APILayer
|
import com.example.mycrib.network.APILayer
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -22,6 +23,9 @@ class TaskViewModel : ViewModel() {
|
|||||||
private val _taskAddNewCustomTaskState = MutableStateFlow<ApiResult<CustomTask>>(ApiResult.Idle)
|
private val _taskAddNewCustomTaskState = MutableStateFlow<ApiResult<CustomTask>>(ApiResult.Idle)
|
||||||
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState
|
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState
|
||||||
|
|
||||||
|
private val _taskCompletionsState = MutableStateFlow<ApiResult<List<TaskCompletionResponse>>>(ApiResult.Idle)
|
||||||
|
val taskCompletionsState: StateFlow<ApiResult<List<TaskCompletionResponse>>> = _taskCompletionsState
|
||||||
|
|
||||||
fun loadTasks(forceRefresh: Boolean = false) {
|
fun loadTasks(forceRefresh: Boolean = false) {
|
||||||
println("TaskViewModel: loadTasks called")
|
println("TaskViewModel: loadTasks called")
|
||||||
viewModelScope.launch {
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ struct ContractorsListView: View {
|
|||||||
|
|
||||||
// Check if upgrade screen should be shown (disables add button)
|
// Check if upgrade screen should be shown (disables add button)
|
||||||
private var shouldShowUpgrade: Bool {
|
private var shouldShowUpgrade: Bool {
|
||||||
subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors")
|
subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors")
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -137,9 +137,7 @@ struct ContractorsListView: View {
|
|||||||
|
|
||||||
// Add Button (disabled when showing upgrade screen)
|
// Add Button (disabled when showing upgrade screen)
|
||||||
Button(action: {
|
Button(action: {
|
||||||
// Check LIVE contractor count before adding
|
if subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors") {
|
||||||
let currentCount = viewModel.contractors.count
|
|
||||||
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "contractors") {
|
|
||||||
showingUpgradePrompt = true
|
showingUpgradePrompt = true
|
||||||
} else {
|
} else {
|
||||||
showingAddSheet = true
|
showingAddSheet = true
|
||||||
@@ -147,9 +145,8 @@ struct ContractorsListView: View {
|
|||||||
}) {
|
}) {
|
||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundColor(shouldShowUpgrade ? Color.appTextSecondary.opacity(0.5) : Color.appPrimary)
|
.foregroundColor(Color.appPrimary)
|
||||||
}
|
}
|
||||||
.disabled(shouldShowUpgrade)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,9 +171,8 @@ struct DocumentsWarrantiesView: View {
|
|||||||
}) {
|
}) {
|
||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundColor(shouldShowUpgrade ? Color.appTextSecondary.opacity(0.5) : Color.appPrimary)
|
.foregroundColor(Color.appPrimary)
|
||||||
}
|
}
|
||||||
.disabled(shouldShowUpgrade)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ struct SummaryCard: View {
|
|||||||
SummaryStatView(
|
SummaryStatView(
|
||||||
icon: "calendar.badge.clock",
|
icon: "calendar.badge.clock",
|
||||||
value: "\(summary.tasksDueNextMonth)",
|
value: "\(summary.tasksDueNextMonth)",
|
||||||
label: "Due This Month"
|
label: "Next 30 Days"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ struct DynamicTaskCard: View {
|
|||||||
let onArchive: () -> Void
|
let onArchive: () -> Void
|
||||||
let onUnarchive: () -> Void
|
let onUnarchive: () -> Void
|
||||||
|
|
||||||
@State private var isCompletionsExpanded = false
|
@State private var showCompletionHistory = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let _ = print("📋 DynamicTaskCard - Task: \(task.title), ButtonTypes: \(buttonTypes)")
|
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()
|
Divider()
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
HStack(spacing: 12) {
|
||||||
HStack {
|
// Actions menu
|
||||||
Image(systemName: "checkmark.circle.fill")
|
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)
|
.foregroundColor(Color.appAccent)
|
||||||
Text("Completions (\(task.completions.count))")
|
.cornerRadius(8)
|
||||||
.font(.caption)
|
.overlay(
|
||||||
.fontWeight(.semibold)
|
RoundedRectangle(cornerRadius: 8)
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.stroke(Color.appAccent, lineWidth: 2)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
.padding(16)
|
||||||
.background(Color.appBackgroundSecondary)
|
.background(Color.appBackgroundSecondary)
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
.shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2)
|
.shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2)
|
||||||
.simultaneousGesture(TapGesture(), including: .subviews)
|
.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 {
|
private func formatDate(_ dateString: String) -> String {
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ struct TaskCard: View {
|
|||||||
isCancelled: false,
|
isCancelled: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
parentTaskId: nil,
|
parentTaskId: nil,
|
||||||
|
completionCount: 0,
|
||||||
completions: [],
|
completions: [],
|
||||||
createdAt: "2024-01-01T00:00:00Z",
|
createdAt: "2024-01-01T00:00:00Z",
|
||||||
updatedAt: "2024-01-01T00:00:00Z"
|
updatedAt: "2024-01-01T00:00:00Z"
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ struct TasksSection: View {
|
|||||||
isCancelled: false,
|
isCancelled: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
parentTaskId: nil,
|
parentTaskId: nil,
|
||||||
|
completionCount: 0,
|
||||||
completions: [],
|
completions: [],
|
||||||
createdAt: "2024-01-01T00:00:00Z",
|
createdAt: "2024-01-01T00:00:00Z",
|
||||||
updatedAt: "2024-01-01T00:00:00Z"
|
updatedAt: "2024-01-01T00:00:00Z"
|
||||||
@@ -141,6 +142,7 @@ struct TasksSection: View {
|
|||||||
isCancelled: false,
|
isCancelled: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
parentTaskId: nil,
|
parentTaskId: nil,
|
||||||
|
completionCount: 3,
|
||||||
completions: [],
|
completions: [],
|
||||||
createdAt: "2024-10-01T00:00:00Z",
|
createdAt: "2024-10-01T00:00:00Z",
|
||||||
updatedAt: "2024-11-05T00:00:00Z"
|
updatedAt: "2024-11-05T00:00:00Z"
|
||||||
|
|||||||
@@ -311,7 +311,8 @@ struct CompleteTaskView: View {
|
|||||||
completedAt: nil,
|
completedAt: nil,
|
||||||
notes: notes.isEmpty ? nil : notes,
|
notes: notes.isEmpty ? nil : notes,
|
||||||
actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0),
|
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
|
// Use TaskCompletionViewModel to create completion
|
||||||
|
|||||||
288
iosApp/iosApp/Task/CompletionHistorySheet.swift
Normal file
288
iosApp/iosApp/Task/CompletionHistorySheet.swift
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ struct TaskFormView: View {
|
|||||||
// Initialize fields from existing task or with defaults
|
// Initialize fields from existing task or with defaults
|
||||||
if let task = existingTask {
|
if let task = existingTask {
|
||||||
_title = State(initialValue: task.title)
|
_title = State(initialValue: task.title)
|
||||||
_description = State(initialValue: task.description ?? "")
|
_description = State(initialValue: task.description_ ?? "")
|
||||||
_selectedCategory = State(initialValue: task.category)
|
_selectedCategory = State(initialValue: task.category)
|
||||||
_selectedFrequency = State(initialValue: task.frequency)
|
_selectedFrequency = State(initialValue: task.frequency)
|
||||||
_selectedPriority = State(initialValue: task.priority)
|
_selectedPriority = State(initialValue: task.priority)
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ class TaskViewModel: ObservableObject {
|
|||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
@Published var actionState: ActionState<TaskActionType> = .idle
|
@Published var actionState: ActionState<TaskActionType> = .idle
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
|
@Published var completions: [TaskCompletionResponse] = []
|
||||||
|
@Published var isLoadingCompletions: Bool = false
|
||||||
|
@Published var completionsError: String?
|
||||||
|
|
||||||
// MARK: - Computed Properties (Backward Compatibility)
|
// MARK: - Computed Properties (Backward Compatibility)
|
||||||
|
|
||||||
@@ -178,4 +181,42 @@ class TaskViewModel: ObservableObject {
|
|||||||
actionState = .idle
|
actionState = .idle
|
||||||
errorMessage = nil
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user