feat: enhance recording controls with tabbed interface

This commit is contained in:
Owen Gretzinger
2025-07-10 17:51:56 -04:00
parent 2ca102f8d6
commit 343b33f623
3 changed files with 222 additions and 108 deletions
+4 -1
View File
@@ -65,17 +65,20 @@ struct CollapsedTranscriptChunk: Identifiable {
struct Meeting: Codable, Identifiable, Hashable { struct Meeting: Codable, Identifiable, Hashable {
let id: UUID let id: UUID
let date: Date let date: Date
var title: String
var transcriptChunks: [TranscriptChunk] var transcriptChunks: [TranscriptChunk]
var userNotes: String var userNotes: String
var generatedNotes: String var generatedNotes: String
init(id: UUID = UUID(), init(id: UUID = UUID(),
date: Date = Date(), date: Date = Date(),
title: String = "",
transcriptChunks: [TranscriptChunk] = [], transcriptChunks: [TranscriptChunk] = [],
userNotes: String = "", userNotes: String = "",
generatedNotes: String = "") { generatedNotes: String = "") {
self.id = id self.id = id
self.date = date self.date = date
self.title = title
self.transcriptChunks = transcriptChunks self.transcriptChunks = transcriptChunks
self.userNotes = userNotes self.userNotes = userNotes
self.generatedNotes = generatedNotes self.generatedNotes = generatedNotes
+66 -1
View File
@@ -2,12 +2,26 @@ import Foundation
import SwiftUI import SwiftUI
import Combine 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 @MainActor
class MeetingViewModel: ObservableObject { class MeetingViewModel: ObservableObject {
@Published var meeting: Meeting @Published var meeting: Meeting
@Published var isGeneratingNotes = false @Published var isGeneratingNotes = false
@Published var errorMessage: String? @Published var errorMessage: String?
@Published var isRecording = false @Published var isRecording = false
@Published var selectedTab: MeetingViewTab = .myNotes
@Published var recordingState: RecordingState = .idle
private let audioManager = AudioManager() private let audioManager = AudioManager()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -26,11 +40,46 @@ class MeetingViewModel: ObservableObject {
audioManager.$isRecording audioManager.$isRecording
.sink { [weak self] isRecording in .sink { [weak self] isRecording in
self?.isRecording = isRecording 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) .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() { func startRecording() {
audioManager.startRecording() audioManager.startRecording()
@@ -79,4 +128,20 @@ class MeetingViewModel: ObservableObject {
NSPasteboard.general.clearContents() NSPasteboard.general.clearContents()
NSPasteboard.general.setString(meeting.generatedNotes, forType: .string) 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)
}
} }
+152 -106
View File
@@ -36,124 +36,84 @@ struct MeetingDetailView: View {
} }
var body: some View { var body: some View {
VStack(spacing: 20) { VStack(alignment: .leading, spacing: 20) {
// Recording Controls // Meeting Title
VStack(spacing: 12) { TextField("Meeting Title", text: $viewModel.meeting.title)
HStack { .font(.title2)
Button { .fontWeight(.semibold)
viewModel.startRecording() .textFieldStyle(.plain)
} label: { .padding(.bottom, 10)
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)
// Transcript and Notes // Controls Section
HStack(spacing: 20) { HStack {
// Transcript Column // Left: Tab Toggles
VStack(alignment: .leading, spacing: 8) { HStack(spacing: 20) {
HStack { ForEach(MeetingViewTab.allCases, id: \.self) { tab in
Text("Live Transcript") Button(action: {
.font(.headline) viewModel.selectedTab = tab
Spacer() }) {
Button { HStack(spacing: 6) {
viewModel.copyTranscript() Image(systemName: viewModel.selectedTab == tab ? "checkmark.circle.fill" : "circle")
} label: { .foregroundColor(viewModel.selectedTab == tab ? .accentColor : .secondary)
Label("Copy", systemImage: "doc.on.doc")
} Text(tab.rawValue)
} .foregroundColor(viewModel.selectedTab == tab ? .primary : .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()
} }
.buttonStyle(.plain)
} }
.frame(maxHeight: .infinity)
.background(Color.gray.opacity(0.05))
.cornerRadius(8)
} }
// Notes Column Spacer()
VStack(alignment: .leading, spacing: 8) {
Text("Your Notes") // Right: Recording and Copy Buttons
.font(.headline) HStack(spacing: 12) {
Button(action: {
TextEditor(text: $viewModel.meeting.userNotes) viewModel.toggleRecording()
.font(.body) }) {
.scrollContentBackground(.hidden) HStack(spacing: 4) {
.background(Color.gray.opacity(0.05)) 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) .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() .padding()
.navigationTitle("Meeting Notes") .navigationTitle("")
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") { Button("OK") {
viewModel.errorMessage = nil viewModel.errorMessage = nil
@@ -162,6 +122,92 @@ struct MeetingDetailView: View {
Text(viewModel.errorMessage ?? "") 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 { #Preview {