// BrainDumpView.swift — Brain-dump text → Claude parses + saves tasks → generate step plans import SwiftUI struct BrainDumpView: View { /// Called when user wants to navigate to the task board var onGoToTasks: (() -> Void)? @State private var rawText = "" @State private var recorder = VoiceDumpRecorder() @State private var parsedTasks: [ParsedTask] = [] @State private var unparseableFragments: [String] = [] @State private var isParsing = false @State private var errorMessage: String? @State private var isDone = false // After dump, fetch actual tasks (with IDs) for step generation @State private var savedTasks: [AppTask] = [] @State private var isFetchingTasks = false @State private var planningTaskId: String? @State private var planError: String? @State private var generatedSteps: [String: [Step]] = [:] var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { if isDone { donePhase } else { inputPhase } } .padding() } .onAppear { recorder.warmUp() } } // MARK: - Input Phase private var inputPhase: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text("Brain Dump") .font(.title2.bold()) Text("Just type everything on your mind. Claude will organize it into tasks for you.") .font(.subheadline) .foregroundStyle(.secondary) } TextEditor(text: $rawText) .font(.body) .frame(minHeight: 200) .padding(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.secondary.opacity(0.3), lineWidth: 1) ) .overlay(alignment: .topLeading) { if rawText.isEmpty { Text("e.g. I need to email Sarah about the project, dentist Thursday, presentation due Friday, buy groceries…") .foregroundStyle(.secondary.opacity(0.5)) .font(.body) .padding(12) .allowsHitTesting(false) } } // Voice dump bar voiceDumpBar if let err = errorMessage { Text(err) .font(.caption) .foregroundStyle(.red) } Button { Task { await parseDump() } } label: { Group { if isParsing { HStack(spacing: 8) { ProgressView() Text("Claude is parsing your tasks…") } } else { Label("Parse & Save Tasks", systemImage: "wand.and.stars") } } .frame(maxWidth: .infinity) .frame(height: 36) } .buttonStyle(.borderedProminent) .disabled(rawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isParsing) } } // MARK: - Done Phase private var donePhase: some View { VStack(spacing: 20) { // Success header VStack(spacing: 8) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 52)) .foregroundStyle(.green) Text("\(parsedTasks.count) task\(parsedTasks.count == 1 ? "" : "s") saved!") .font(.title2.bold()) Text("Tasks are in your board. Generate steps to break them into 5–15 min chunks.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) // Parsed task previews — 2-column grid if !parsedTasks.isEmpty { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { ForEach(parsedTasks) { task in ParsedTaskPreviewRow(task: task) } } } // Unparseable fragments if !unparseableFragments.isEmpty { VStack(alignment: .leading, spacing: 6) { Text("Couldn't parse:") .font(.caption.bold()) .foregroundStyle(.secondary) ForEach(unparseableFragments, id: \.self) { fragment in Text("• \(fragment)") .font(.caption) .foregroundStyle(.secondary) .italic() } } .padding() .background(Color.secondary.opacity(0.08)) .clipShape(.rect(cornerRadius: 8)) } Divider() // Step generation section VStack(alignment: .leading, spacing: 10) { HStack { Text("Generate Steps") .font(.headline) if isFetchingTasks { ProgressView().scaleEffect(0.8) } Spacer() } if let err = planError { Text(err) .font(.caption) .foregroundStyle(.red) } if savedTasks.isEmpty || isFetchingTasks { HStack(spacing: 8) { ProgressView().scaleEffect(0.8) Text("Generating steps…") .font(.caption) .foregroundStyle(.secondary) } } else { ForEach(savedTasks.prefix(parsedTasks.count)) { task in VStack(alignment: .leading, spacing: 6) { // Task header HStack { Text(task.title) .font(.subheadline.bold()) .lineLimit(1) Spacer() if task.planType == nil { HStack(spacing: 4) { ProgressView().scaleEffect(0.7) Text("Generating…") .font(.caption2) .foregroundStyle(.secondary) } } } // Steps list if let steps = generatedSteps[task.id], !steps.isEmpty { VStack(alignment: .leading, spacing: 3) { ForEach(Array(steps.enumerated()), id: \.element.id) { i, step in HStack(alignment: .top, spacing: 6) { Text("\(i + 1).") .font(.caption2) .foregroundStyle(.secondary) .frame(width: 16, alignment: .trailing) Text(step.title) .font(.caption) .foregroundStyle(.primary) if let mins = step.estimatedMinutes { Spacer() Text("~\(mins)m") .font(.caption2) .foregroundStyle(.secondary) } } } } .padding(.leading, 4) } } .padding(.horizontal, 10) .padding(.vertical, 8) .background(Color.secondary.opacity(0.06)) .clipShape(.rect(cornerRadius: 8)) } } } Divider() // Bottom actions HStack(spacing: 12) { Button { onGoToTasks?() } label: { Label("Go to Task Board", systemImage: "checklist") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) Button("Dump more") { reset() } .buttonStyle(.bordered) } } } // MARK: - Voice Dump Bar private var voiceDumpBar: some View { HStack(spacing: 10) { Button { if recorder.isRecording { Task { await recorder.stopRecording() } } else { Task { await startVoiceRecording() } } } label: { Label( recorder.isRecording ? "Stop" : (recorder.isTranscribing ? "Transcribing…" : "Voice Dump"), systemImage: recorder.isRecording ? "stop.circle.fill" : "mic.fill" ) .foregroundStyle(recorder.isRecording ? .red : .accentColor) .symbolEffect(.pulse, isActive: recorder.isRecording || recorder.isTranscribing) } .buttonStyle(.bordered) .disabled(recorder.isTranscribing) if recorder.isRecording { Text("Listening…") .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } else if recorder.isTranscribing { HStack(spacing: 6) { ProgressView().scaleEffect(0.7) Text("Whisper is transcribing…") .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) } else if recorder.permissionDenied { Text("Microphone access denied in System Settings.") .font(.caption) .foregroundStyle(.red) } } .onChange(of: recorder.isTranscribing) { _, isNowTranscribing in // Append transcript into rawText once Whisper finishes if !isNowTranscribing && !recorder.liveTranscript.isEmpty { if !rawText.isEmpty { rawText += "\n" } rawText += recorder.liveTranscript recorder.liveTranscript = "" } } } private func startVoiceRecording() async { await recorder.requestPermissions() guard !recorder.permissionDenied else { return } recorder.startRecording() } // MARK: - Actions private func parseDump() async { isParsing = true errorMessage = nil do { // Backend parses AND saves tasks in one call — we just display the result let response = try await APIClient.shared.brainDump(rawText: rawText) parsedTasks = response.parsedTasks unparseableFragments = response.unparseableFragments isDone = true // Fetch actual tasks (with IDs) so user can generate steps await fetchLatestTasks() } catch { errorMessage = error.localizedDescription } isParsing = false } /// Fetch the most recently created tasks, then auto-generate steps for all of them private func fetchLatestTasks() async { isFetchingTasks = true do { let all = try await APIClient.shared.getTasks() let parsedTitles = Set(parsedTasks.map(\.title)) savedTasks = all.filter { parsedTitles.contains($0.title) } if savedTasks.isEmpty { savedTasks = Array(all.prefix(parsedTasks.count)) } } catch {} isFetchingTasks = false // Auto-generate steps for any task that doesn't have a plan yet let tasksNeedingPlan = savedTasks.filter { $0.planType == nil } await withTaskGroup(of: Void.self) { group in for task in tasksNeedingPlan { group.addTask { await generatePlan(task) } } } } private func generatePlan(_ task: AppTask) async { planningTaskId = task.id planError = nil do { let response = try await APIClient.shared.planTask(taskId: task.id) generatedSteps[task.id] = response.steps.sorted { $0.sortOrder < $1.sortOrder } // Mark the task as planned locally if let idx = savedTasks.firstIndex(where: { $0.id == task.id }) { savedTasks[idx].planType = response.planType } } catch { planError = error.localizedDescription } planningTaskId = nil } private func reset() { rawText = "" parsedTasks = [] unparseableFragments = [] savedTasks = [] generatedSteps = [:] errorMessage = nil planningTaskId = nil planError = nil isDone = false } } // MARK: - Parsed Task Preview Row private struct ParsedTaskPreviewRow: View { let task: ParsedTask private var priorityColor: Color { switch task.priority { case 4: return .red case 3: return .orange case 2: return .yellow default: return .green } } var body: some View { HStack(alignment: .top, spacing: 10) { Circle() .fill(priorityColor) .frame(width: 7, height: 7) .padding(.top, 7) VStack(alignment: .leading, spacing: 3) { Text(task.title) .font(.subheadline.bold()) HStack(spacing: 8) { if let mins = task.estimatedMinutes { Label("~\(mins)m", systemImage: "clock") .font(.caption2) .foregroundStyle(.secondary) } if let dl = task.deadline { Label(shortDate(dl), systemImage: "calendar") .font(.caption2) .foregroundStyle(.secondary) } ForEach(task.tags, id: \.self) { tag in Text(tag) .font(.caption2) .padding(.horizontal, 5) .padding(.vertical, 2) .background(Color.accentColor.opacity(0.1)) .clipShape(.capsule) } } } } .padding() .background(Color.secondary.opacity(0.06)) .clipShape(.rect(cornerRadius: 10)) } private func shortDate(_ iso: String) -> String { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let d = f.date(from: iso) { return d.formatted(.dateTime.month(.abbreviated).day()) } f.formatOptions = [.withInternetDateTime] if let d = f.date(from: iso) { return d.formatted(.dateTime.month(.abbreviated).day()) } return iso } }