Files
LockInBroMacOS/LockInBro/BrainDumpView.swift

431 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 515 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
}
}