From 343b33f6236ad66117deed738c47fdad28aaa5c4 Mon Sep 17 00:00:00 2001 From: Owen Gretzinger Date: Thu, 10 Jul 2025 17:51:56 -0400 Subject: [PATCH] feat: enhance recording controls with tabbed interface --- notetaker/Models/Meeting.swift | 5 +- notetaker/ViewModels/MeetingViewModel.swift | 67 ++++- notetaker/Views/MeetingDetailView.swift | 258 ++++++++++++-------- 3 files changed, 222 insertions(+), 108 deletions(-) diff --git a/notetaker/Models/Meeting.swift b/notetaker/Models/Meeting.swift index 3e01aa6..e5bdf07 100644 --- a/notetaker/Models/Meeting.swift +++ b/notetaker/Models/Meeting.swift @@ -65,17 +65,20 @@ struct CollapsedTranscriptChunk: Identifiable { struct Meeting: Codable, Identifiable, Hashable { let id: UUID let date: Date + var title: String var transcriptChunks: [TranscriptChunk] var userNotes: String var generatedNotes: String init(id: UUID = UUID(), - date: Date = Date(), + date: Date = Date(), + title: String = "", transcriptChunks: [TranscriptChunk] = [], userNotes: String = "", generatedNotes: String = "") { self.id = id self.date = date + self.title = title self.transcriptChunks = transcriptChunks self.userNotes = userNotes self.generatedNotes = generatedNotes diff --git a/notetaker/ViewModels/MeetingViewModel.swift b/notetaker/ViewModels/MeetingViewModel.swift index b76e411..2d4ad89 100644 --- a/notetaker/ViewModels/MeetingViewModel.swift +++ b/notetaker/ViewModels/MeetingViewModel.swift @@ -2,12 +2,26 @@ import Foundation import SwiftUI import Combine +enum MeetingViewTab: String, CaseIterable { + case myNotes = "My Notes" + case transcript = "Transcript" + case enhancedNotes = "Enhanced Notes" +} + +enum RecordingState { + case idle // Not recording, shows "Transcribe" + case recording // Recording, shows "Stop" + case paused // Paused, shows "Resume" +} + @MainActor class MeetingViewModel: ObservableObject { @Published var meeting: Meeting @Published var isGeneratingNotes = false @Published var errorMessage: String? @Published var isRecording = false + @Published var selectedTab: MeetingViewTab = .myNotes + @Published var recordingState: RecordingState = .idle private let audioManager = AudioManager() private var cancellables = Set() @@ -26,11 +40,46 @@ class MeetingViewModel: ObservableObject { audioManager.$isRecording .sink { [weak self] isRecording in self?.isRecording = isRecording + self?.updateRecordingState() + } + .store(in: &cancellables) + + // Auto-save when meeting properties change + $meeting + .debounce(for: .milliseconds(500), scheduler: RunLoop.main) + .sink { [weak self] _ in + self?.saveMeeting() } .store(in: &cancellables) } - + private func updateRecordingState() { + if isRecording { + recordingState = .recording + } else if recordingState == .recording { + recordingState = .paused + } + } + + var recordingButtonText: String { + switch recordingState { + case .idle: + return "Transcribe" + case .recording: + return "Stop" + case .paused: + return "Resume" + } + } + + func toggleRecording() { + switch recordingState { + case .idle, .paused: + startRecording() + case .recording: + stopRecording() + } + } func startRecording() { audioManager.startRecording() @@ -79,4 +128,20 @@ class MeetingViewModel: ObservableObject { NSPasteboard.general.clearContents() NSPasteboard.general.setString(meeting.generatedNotes, forType: .string) } + + func copyCurrentTabContent() { + NSPasteboard.general.clearContents() + + let content: String + switch selectedTab { + case .myNotes: + content = meeting.userNotes + case .transcript: + content = meeting.formattedTranscript + case .enhancedNotes: + content = meeting.generatedNotes + } + + NSPasteboard.general.setString(content, forType: .string) + } } \ No newline at end of file diff --git a/notetaker/Views/MeetingDetailView.swift b/notetaker/Views/MeetingDetailView.swift index a04b439..f83169f 100644 --- a/notetaker/Views/MeetingDetailView.swift +++ b/notetaker/Views/MeetingDetailView.swift @@ -36,124 +36,84 @@ struct MeetingDetailView: View { } var body: some View { - VStack(spacing: 20) { - // Recording Controls - VStack(spacing: 12) { - HStack { - Button { - viewModel.startRecording() - } label: { - Label("Start Recording", systemImage: "record.circle") - } - .disabled(viewModel.isRecording) - .buttonStyle(.borderedProminent) - - Button { - viewModel.stopRecording() - } label: { - Label("Stop Recording", systemImage: "stop.circle") - } - .disabled(!viewModel.isRecording) - .buttonStyle(.bordered) - } - - if viewModel.isRecording { - Label("Recording...", systemImage: "dot.radiowaves.left.and.right") - .foregroundColor(.red) - } - } - .padding() - .background(Color.gray.opacity(0.1)) - .cornerRadius(10) + VStack(alignment: .leading, spacing: 20) { + // Meeting Title + TextField("Meeting Title", text: $viewModel.meeting.title) + .font(.title2) + .fontWeight(.semibold) + .textFieldStyle(.plain) + .padding(.bottom, 10) - // Transcript and Notes - HStack(spacing: 20) { - // Transcript Column - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Live Transcript") - .font(.headline) - Spacer() - Button { - viewModel.copyTranscript() - } label: { - Label("Copy", systemImage: "doc.on.doc") - } - } - - ScrollView { - if viewModel.meeting.collapsedTranscriptChunks.isEmpty { - Text("Transcript will appear here...") - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .foregroundColor(.secondary) - } else { - LazyVStack(alignment: .leading, spacing: 4) { - ForEach(viewModel.meeting.collapsedTranscriptChunks) { chunk in - CollapsedTranscriptChunkView(chunk: chunk) - } + // Controls Section + HStack { + // Left: Tab Toggles + HStack(spacing: 20) { + ForEach(MeetingViewTab.allCases, id: \.self) { tab in + Button(action: { + viewModel.selectedTab = tab + }) { + HStack(spacing: 6) { + Image(systemName: viewModel.selectedTab == tab ? "checkmark.circle.fill" : "circle") + .foregroundColor(viewModel.selectedTab == tab ? .accentColor : .secondary) + + Text(tab.rawValue) + .foregroundColor(viewModel.selectedTab == tab ? .primary : .secondary) } - .padding() } + .buttonStyle(.plain) } - .frame(maxHeight: .infinity) - .background(Color.gray.opacity(0.05)) - .cornerRadius(8) } - // Notes Column - VStack(alignment: .leading, spacing: 8) { - Text("Your Notes") - .font(.headline) - - TextEditor(text: $viewModel.meeting.userNotes) - .font(.body) - .scrollContentBackground(.hidden) - .background(Color.gray.opacity(0.05)) + Spacer() + + // Right: Recording and Copy Buttons + HStack(spacing: 12) { + Button(action: { + viewModel.toggleRecording() + }) { + HStack(spacing: 4) { + Image(systemName: viewModel.recordingState == .recording ? "stop.circle.fill" : "record.circle") + .foregroundColor(viewModel.recordingState == .recording ? .red : .accentColor) + Text(viewModel.recordingButtonText) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(viewModel.recordingState == .recording ? Color.red.opacity(0.1) : Color.accentColor.opacity(0.1)) .cornerRadius(8) - } - } - .frame(maxHeight: 300) - - // Generated Notes Section - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Generated Notes") - .font(.headline) - Spacer() - - if viewModel.isGeneratingNotes { - ProgressView() - .scaleEffect(0.7) - } else { - Button { - Task { - await viewModel.generateNotes() - } - } label: { - Label("Generate", systemImage: "sparkles") - } - .disabled(viewModel.meeting.transcript.isEmpty) - - Button { - viewModel.copyNotes() - } label: { - Label("Copy", systemImage: "doc.on.doc") - } - .disabled(viewModel.meeting.generatedNotes.isEmpty) } + .buttonStyle(.plain) + + Button(action: { + viewModel.copyCurrentTabContent() + }) { + HStack(spacing: 4) { + Image(systemName: "doc.on.doc") + Text("Copy") + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + .buttonStyle(.plain) } - - TextEditor(text: $viewModel.meeting.generatedNotes) - .font(.body) - .scrollContentBackground(.hidden) - .background(Color.gray.opacity(0.05)) - .cornerRadius(8) - .frame(minHeight: 150) } + + // Content Area + VStack(alignment: .leading, spacing: 8) { + switch viewModel.selectedTab { + case .myNotes: + myNotesView + case .transcript: + transcriptView + case .enhancedNotes: + enhancedNotesView + } + } + .frame(maxHeight: .infinity) } .padding() - .navigationTitle("Meeting Notes") + .navigationTitle("") .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { Button("OK") { viewModel.errorMessage = nil @@ -162,6 +122,92 @@ struct MeetingDetailView: View { Text(viewModel.errorMessage ?? "") } } + + // MARK: - Content Views + + private var myNotesView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("My Notes") + .font(.headline) + .foregroundColor(.secondary) + + TextEditor(text: $viewModel.meeting.userNotes) + .font(.body) + .scrollContentBackground(.hidden) + .background(Color.gray.opacity(0.05)) + .cornerRadius(8) + .frame(maxHeight: .infinity) + } + } + + private var transcriptView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Transcript") + .font(.headline) + .foregroundColor(.secondary) + + ScrollView { + if viewModel.meeting.collapsedTranscriptChunks.isEmpty { + Text("Transcript will appear here...") + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .foregroundColor(.secondary) + } else { + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(viewModel.meeting.collapsedTranscriptChunks) { chunk in + CollapsedTranscriptChunkView(chunk: chunk) + } + } + .padding() + } + } + .frame(maxHeight: .infinity) + .background(Color.gray.opacity(0.05)) + .cornerRadius(8) + } + } + + private var enhancedNotesView: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Enhanced Notes") + .font(.headline) + .foregroundColor(.secondary) + + Spacer() + + if viewModel.isGeneratingNotes { + ProgressView() + .scaleEffect(0.7) + } else { + Button(action: { + Task { + await viewModel.generateNotes() + } + }) { + HStack(spacing: 4) { + Image(systemName: "sparkles") + Text("Generate") + } + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(6) + } + .buttonStyle(.plain) + .disabled(viewModel.meeting.transcript.isEmpty) + } + } + + TextEditor(text: $viewModel.meeting.generatedNotes) + .font(.body) + .scrollContentBackground(.hidden) + .background(Color.gray.opacity(0.05)) + .cornerRadius(8) + .frame(maxHeight: .infinity) + } + } } #Preview {