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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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