feat: actually implement system audio + refactor

This commit is contained in:
Owen Gretzinger
2025-07-10 16:45:19 -04:00
parent 417d751d7a
commit 297bb40b89
21 changed files with 1307 additions and 615 deletions
+136
View File
@@ -0,0 +1,136 @@
import SwiftUI
struct MeetingDetailView: View {
@StateObject private var viewModel: MeetingViewModel
init(meeting: Meeting) {
self._viewModel = StateObject(wrappedValue: MeetingViewModel(meeting: meeting))
}
var body: some View {
VStack(spacing: 20) {
// Recording Controls
VStack(spacing: 12) {
Toggle("Capture System Audio", isOn: $viewModel.captureSystemAudio)
.disabled(viewModel.isRecording)
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)
// 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 {
Text(viewModel.meeting.transcript.isEmpty ? "Transcript will appear here..." : viewModel.meeting.transcript)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.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))
.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)
}
}
TextEditor(text: $viewModel.meeting.generatedNotes)
.font(.body)
.scrollContentBackground(.hidden)
.background(Color.gray.opacity(0.05))
.cornerRadius(8)
.frame(minHeight: 150)
}
}
.padding()
.navigationTitle("Meeting Notes")
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") {
viewModel.errorMessage = nil
}
} message: {
Text(viewModel.errorMessage ?? "")
}
}
}
#Preview {
NavigationStack {
MeetingDetailView(meeting: Meeting())
}
}
+78
View File
@@ -0,0 +1,78 @@
import SwiftUI
struct MeetingListView: View {
@StateObject private var viewModel = MeetingListViewModel()
@State private var navigationPath = NavigationPath()
var body: some View {
NavigationStack(path: $navigationPath) {
List {
if viewModel.meetings.isEmpty && !viewModel.isLoading {
ContentUnavailableView(
"No Meetings Yet",
systemImage: "mic.slash",
description: Text("Start a new meeting to begin transcribing")
)
} else {
ForEach(viewModel.meetings) { meeting in
NavigationLink(value: meeting) {
MeetingRowView(meeting: meeting)
}
}
.onDelete { indexSet in
for index in indexSet {
viewModel.deleteMeeting(viewModel.meetings[index])
}
}
}
}
.navigationTitle("Meetings")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
let newMeeting = viewModel.createNewMeeting()
navigationPath.append(newMeeting)
} label: {
Label("New Meeting", systemImage: "plus")
}
}
}
.navigationDestination(for: Meeting.self) { meeting in
MeetingDetailView(meeting: meeting)
}
}
.overlay {
if viewModel.isLoading {
ProgressView("Loading meetings...")
}
}
}
}
struct MeetingRowView: View {
let meeting: Meeting
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(meeting.date, style: .date)
.font(.headline)
if !meeting.transcript.isEmpty {
Text(meeting.transcript)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
} else {
Text("No transcript")
.font(.caption)
.foregroundColor(.secondary)
.italic()
}
}
.padding(.vertical, 4)
}
}
#Preview {
MeetingListView()
}
+96
View File
@@ -0,0 +1,96 @@
import SwiftUI
struct SettingsView: View {
@StateObject private var viewModel = SettingsViewModel()
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
Section("API Keys") {
SecureField("Deepgram API Key", text: $viewModel.settings.deepgramKey)
.textFieldStyle(.roundedBorder)
SecureField("OpenAI API Key", text: $viewModel.settings.openAIKey)
.textFieldStyle(.roundedBorder)
}
Section("Personal Information") {
VStack(alignment: .leading) {
Text("About Yourself")
.font(.caption)
.foregroundColor(.secondary)
TextEditor(text: $viewModel.settings.userBlurb)
.frame(minHeight: 80)
.scrollContentBackground(.hidden)
.background(Color.gray.opacity(0.05))
.cornerRadius(8)
}
}
Section("AI Generation") {
VStack(alignment: .leading) {
HStack {
Text("System Prompt")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Button("Reset to Default") {
viewModel.resetToDefaults()
}
.font(.caption)
}
TextEditor(text: $viewModel.settings.systemPrompt)
.frame(minHeight: 120)
.scrollContentBackground(.hidden)
.background(Color.gray.opacity(0.05))
.cornerRadius(8)
}
}
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
viewModel.saveSettings()
}
.disabled(viewModel.isSaving)
}
}
.overlay {
if viewModel.saveSuccessful {
VStack {
Spacer()
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Settings saved successfully")
}
.padding()
.background(Color.green.opacity(0.1))
.cornerRadius(10)
.padding()
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") {
viewModel.errorMessage = nil
}
} message: {
Text(viewModel.errorMessage ?? "")
}
}
}
}
#Preview {
SettingsView()
}