// TaskBoardView.swift — Task list with priority sorting and step progress import SwiftUI struct TaskBoardView: View { @Environment(SessionManager.self) private var session @State private var tasks: [AppTask] = [] @State private var isLoading = false @State private var errorMessage: String? @State private var selectedFilter: String? = nil @State private var expandedTaskId: String? @State private var taskSteps: [String: [Step]] = [:] @State private var loadingStepsFor: String? @State private var editingTask: AppTask? // (statusValue, displayLabel) — nil statusValue means "all tasks" private let filters: [(String?, String)] = [ (nil, "All"), ("in_progress", "In Progress"), ("pending", "Pending"), ("done", "Done") ] var body: some View { VStack(spacing: 0) { // Filter tabs HStack(spacing: 4) { ForEach(filters, id: \.1) { filter in Button(filter.1) { selectedFilter = filter.0 Task { await loadTasks() } } .buttonStyle(.plain) .padding(.horizontal, 12) .padding(.vertical, 6) .background(selectedFilter == filter.0 ? Color.accentColor : Color.clear) .foregroundStyle(selectedFilter == filter.0 ? .white : .primary) .clipShape(.capsule) .fontWeight(selectedFilter == filter.0 ? .semibold : .regular) } Spacer() Button { Task { await loadTasks() } } label: { Image(systemName: "arrow.clockwise") } .buttonStyle(.plain) .help("Refresh") } .padding(.horizontal) .padding(.vertical, 8) Divider() if isLoading { ProgressView("Loading tasks…") .frame(maxWidth: .infinity, maxHeight: .infinity) } else if tasks.isEmpty { ContentUnavailableView( "No tasks", systemImage: "checklist", description: Text("Use Brain Dump to capture your tasks") ) } else { ScrollView { LazyVStack(spacing: 8) { ForEach(sortedTasks) { task in TaskRow( task: task, isExpanded: expandedTaskId == task.id, steps: taskSteps[task.id] ?? [], isLoadingSteps: loadingStepsFor == task.id, onToggle: { toggleExpanded(task) }, onStartFocus: { startFocus(task) }, onEdit: { editingTask = task }, onDelete: { Task { await deleteTask(task) } }, onCompleteStep: { step in Task { await completeStep(step, taskId: task.id) } } ) } } .padding() } } if let err = errorMessage ?? session.errorMessage { Text(err) .font(.caption) .foregroundStyle(.red) .padding(.horizontal) .padding(.bottom, 8) } if session.isLoading { HStack(spacing: 8) { ProgressView().controlSize(.small) Text("Starting session…") .font(.caption) .foregroundStyle(.secondary) } .padding(.horizontal) .padding(.bottom, 8) } } .task { await loadTasks() } .sheet(item: $editingTask) { task in EditTaskSheet(task: task) { updated in if let idx = tasks.firstIndex(where: { $0.id == updated.id }) { tasks[idx] = updated } editingTask = nil } onDismiss: { editingTask = nil } } } private var sortedTasks: [AppTask] { tasks.sorted { a, b in if a.priority != b.priority { return a.priority > b.priority } return a.createdAt > b.createdAt } } private func loadTasks() async { isLoading = true errorMessage = nil do { var all = try await APIClient.shared.getTasks(status: selectedFilter) // Soft-deleted tasks have status='deferred' — hide them from the All view if selectedFilter == nil { all = all.filter { $0.status != "deferred" } } tasks = all } catch { errorMessage = error.localizedDescription } isLoading = false } private func toggleExpanded(_ task: AppTask) { if expandedTaskId == task.id { expandedTaskId = nil } else { expandedTaskId = task.id if taskSteps[task.id] == nil { Task { await loadSteps(taskId: task.id) } } } } private func loadSteps(taskId: String) async { loadingStepsFor = taskId do { taskSteps[taskId] = try await APIClient.shared.getSteps(taskId: taskId) } catch { // Silently fail for step loading } loadingStepsFor = nil } private func startFocus(_ task: AppTask) { session.errorMessage = nil Task { await session.startSession(task: task) } } private func deleteTask(_ task: AppTask) async { // Optimistically remove from UI immediately tasks.removeAll { $0.id == task.id } do { try await APIClient.shared.deleteTask(taskId: task.id) } catch { // Restore on failure tasks.append(task) errorMessage = error.localizedDescription } } private func completeStep(_ step: Step, taskId: String) async { do { let updated = try await APIClient.shared.completeStep(stepId: step.id) if var steps = taskSteps[taskId] { if let idx = steps.firstIndex(where: { $0.id == updated.id }) { steps[idx] = updated taskSteps[taskId] = steps } } } catch {} } } // MARK: - Task Row private struct TaskRow: View { let task: AppTask let isExpanded: Bool let steps: [Step] let isLoadingSteps: Bool let onToggle: () -> Void let onStartFocus: () -> Void let onEdit: () -> Void let onDelete: () -> Void let onCompleteStep: (Step) -> Void private var completedSteps: Int { steps.filter(\.isDone).count } var body: some View { VStack(alignment: .leading, spacing: 0) { // Header row HStack(spacing: 10) { // Priority badge PriorityBadge(priority: task.priority) VStack(alignment: .leading, spacing: 2) { Text(task.title) .font(.headline) .lineLimit(2) HStack(spacing: 8) { if let deadline = task.deadline { Label(formatDeadline(deadline), systemImage: "calendar") .font(.caption) .foregroundStyle(.secondary) } if !steps.isEmpty { Label("\(completedSteps)/\(steps.count) steps", systemImage: "checklist") .font(.caption) .foregroundStyle(.secondary) } if let mins = task.estimatedMinutes { Label("~\(mins)m", systemImage: "clock") .font(.caption) .foregroundStyle(.secondary) } } } Spacer() // Actions HStack(spacing: 6) { Button { onStartFocus() } label: { Image(systemName: "play.circle.fill") .foregroundStyle(.blue) } .buttonStyle(.plain) .help("Start focus session") Button { onEdit() } label: { Image(systemName: "pencil.circle") .foregroundStyle(.secondary) } .buttonStyle(.plain) .help("Edit task") Button(role: .destructive) { onDelete() } label: { Image(systemName: "trash") .foregroundStyle(.red.opacity(0.7)) } .buttonStyle(.plain) .help("Delete task") Button { onToggle() } label: { Image(systemName: isExpanded ? "chevron.up" : "chevron.down") .foregroundStyle(.secondary) } .buttonStyle(.plain) } } .padding() // Step progress bar if !steps.isEmpty { ProgressView(value: Double(completedSteps), total: Double(steps.count)) .progressViewStyle(.linear) .padding(.horizontal) .padding(.bottom, isExpanded ? 0 : 8) } // Expanded steps list if isExpanded { Divider().padding(.horizontal) if isLoadingSteps { ProgressView() .frame(maxWidth: .infinity) .padding() } else if steps.isEmpty { Text("No steps yet — generate a plan to get started") .font(.caption) .foregroundStyle(.secondary) .padding() } else { VStack(alignment: .leading, spacing: 4) { ForEach(steps.sorted { $0.sortOrder < $1.sortOrder }) { step in StepRow(step: step, onComplete: { onCompleteStep(step) }) } } .padding() } } } .background(.background) .clipShape(.rect(cornerRadius: 10)) .shadow(color: .black.opacity(0.07), radius: 4, x: 0, y: 2) } private func formatDeadline(_ iso: String) -> String { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let date = formatter.date(from: iso) else { formatter.formatOptions = [.withInternetDateTime] guard let date2 = formatter.date(from: iso) else { return iso } return RelativeDateTimeFormatter().localizedString(for: date2, relativeTo: .now) } return RelativeDateTimeFormatter().localizedString(for: date, relativeTo: .now) } } // MARK: - Step Row private struct StepRow: View { let step: Step let onComplete: () -> Void var body: some View { HStack(spacing: 10) { Button { if !step.isDone { onComplete() } } label: { Image(systemName: step.isDone ? "checkmark.circle.fill" : (step.isActive ? "circle.fill" : "circle")) .foregroundStyle(step.isDone ? .green : (step.isActive ? .blue : .secondary)) } .buttonStyle(.plain) VStack(alignment: .leading, spacing: 2) { Text(step.title) .font(.subheadline) .strikethrough(step.isDone) .foregroundStyle(step.isDone ? .secondary : .primary) if let note = step.checkpointNote { Text(note) .font(.caption) .foregroundStyle(.blue) .italic() } } Spacer() if let mins = step.estimatedMinutes { Text("~\(mins)m") .font(.caption2) .foregroundStyle(.secondary) } } .padding(.vertical, 2) } } // MARK: - Edit Task Sheet private struct EditTaskSheet: View { let task: AppTask let onSave: (AppTask) -> Void let onDismiss: () -> Void @State private var title: String @State private var description: String @State private var priority: Int @State private var isSaving = false @State private var error: String? init(task: AppTask, onSave: @escaping (AppTask) -> Void, onDismiss: @escaping () -> Void) { self.task = task self.onSave = onSave self.onDismiss = onDismiss _title = State(initialValue: task.title) _description = State(initialValue: task.description ?? "") _priority = State(initialValue: task.priority) } var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { Text("Edit Task").font(.headline) Spacer() Button("Cancel") { onDismiss() }.buttonStyle(.bordered) } VStack(alignment: .leading, spacing: 4) { Text("Title").font(.caption).foregroundStyle(.secondary) TextField("Title", text: $title) .textFieldStyle(.roundedBorder) } VStack(alignment: .leading, spacing: 4) { Text("Description").font(.caption).foregroundStyle(.secondary) TextEditor(text: $description) .font(.body) .frame(height: 80) .padding(4) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.3))) } VStack(alignment: .leading, spacing: 4) { Text("Priority").font(.caption).foregroundStyle(.secondary) Picker("Priority", selection: $priority) { Text("Low").tag(1) Text("Medium").tag(2) Text("High").tag(3) Text("Urgent").tag(4) } .pickerStyle(.segmented) } if let err = error { Text(err).font(.caption).foregroundStyle(.red) } Button { Task { await save() } } label: { Group { if isSaving { ProgressView() } else { Text("Save Changes") } } .frame(maxWidth: .infinity) .frame(height: 32) } .buttonStyle(.borderedProminent) .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving) } .padding(24) .frame(width: 380) } private func save() async { isSaving = true error = nil do { let updated = try await APIClient.shared.updateTask( taskId: task.id, title: title.trimmingCharacters(in: .whitespacesAndNewlines), description: description.isEmpty ? nil : description, priority: priority ) onSave(updated) } catch { self.error = error.localizedDescription } isSaving = false } } // MARK: - Priority Badge private struct PriorityBadge: View { let priority: Int private var color: Color { switch priority { case 4: return .red case 3: return .orange case 2: return .yellow case 1: return .green default: return .gray } } private var label: String { switch priority { case 4: return "URGENT" case 3: return "HIGH" case 2: return "MED" case 1: return "LOW" default: return "—" } } var body: some View { Text(label) .font(.system(size: 9, weight: .bold)) .padding(.horizontal, 5) .padding(.vertical, 3) .background(color.opacity(0.2)) .foregroundStyle(color) .clipShape(.capsule) } }