feat: enhance recording controls with tabbed interface
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user