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