Add task completion history feature and UI improvements

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

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

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

View File

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