Files
meetingnotes/meetingnotes/ViewModels/MeetingViewModel.swift
T

297 lines
10 KiB
Swift

import Foundation
import SwiftUI
import Combine
import PostHog
// Add notification name for meeting saved events
extension Notification.Name {
static let meetingSaved = Notification.Name("MeetingSaved")
static let meetingDeleted = Notification.Name("MeetingDeleted")
}
enum MeetingViewTab: String, CaseIterable {
case myNotes = "My Notes"
case transcript = "Transcript"
case enhancedNotes = "Enhanced Notes"
}
enum RecordingState {
case idle // Not recording, shows "Transcribe" or "Resume" based on transcript content
case recording // Recording, shows "Stop"
}
@MainActor
class MeetingViewModel: ObservableObject {
@Published var meeting: Meeting
@Published var isGeneratingNotes = false
@Published var errorMessage: String?
@Published var isRecording = false
@Published var selectedTab: MeetingViewTab = .transcript // Default to transcript tab
@Published var recordingState: RecordingState = .idle
@Published var isDeleted = false
@Published var templates: [NoteTemplate] = []
@Published var selectedTemplateId: UUID?
private let audioManager = AudioManager()
private var cancellables = Set<AnyCancellable>()
private var isNewMeeting = false
// Computed property to check if meeting is empty
var isEmpty: Bool {
return meeting.transcriptChunks.isEmpty &&
meeting.userNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
meeting.generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
init(meeting: Meeting = Meeting()) {
// Load the latest version of the meeting from storage if it exists
if let savedMeeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meeting.id }) {
print("🔄 Loading latest version of meeting: \(meeting.id)")
self.meeting = savedMeeting
} else {
print("🆕 Using provided meeting: \(meeting.id)")
self.meeting = meeting
}
// Detect if this is a new meeting based on content, not storage existence
isNewMeeting = isEmpty
// Set initial tab based on notes existence
if !self.meeting.generatedNotes.isEmpty {
selectedTab = .enhancedNotes
} else {
selectedTab = .transcript
}
// Load templates and selected template
loadTemplates()
// Observe template selection: save to meeting and regenerate notes on changes (skip initial)
$selectedTemplateId
.dropFirst()
.sink { [weak self] newTemplateId in
guard let self = self else { return }
self.meeting.templateId = newTemplateId
Task {
await self.generateNotes()
}
}
.store(in: &cancellables)
// NEW: Seed the audio manager with any existing transcript chunks so the initial
// published value doesn't overwrite the saved transcript with an empty array.
audioManager.transcriptChunks = self.meeting.transcriptChunks
// Update meeting transcript chunks when audio manager transcript chunks change
audioManager.$transcriptChunks
.sink { [weak self] newChunks in
self?.meeting.transcriptChunks = newChunks
}
.store(in: &cancellables)
// Update isRecording when audio manager recording state changes
audioManager.$isRecording
.sink { [weak self] isRecording in
self?.isRecording = isRecording
self?.updateRecordingState()
}
.store(in: &cancellables)
// Update error message when audio manager encounters errors
audioManager.$errorMessage
.compactMap { $0 }
.sink { [weak self] errorMessage in
self?.errorMessage = errorMessage
print("🚨 Audio Manager Error: \(errorMessage)")
}
.store(in: &cancellables)
// Auto-save when meeting properties change
$meeting
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.sink { [weak self] meeting in
print("🔄 Auto-saving meeting: \(meeting.id) - title: '\(meeting.title)', notes: '\(meeting.userNotes.prefix(50))...'")
self?.saveMeeting()
}
.store(in: &cancellables)
// Auto-start recording for empty meetings
if isNewMeeting {
print("🚀 Auto-starting recording for empty meeting")
startRecording()
}
}
private func updateRecordingState() {
if isRecording {
recordingState = .recording
} else {
recordingState = .idle
}
}
var recordingButtonText: String {
switch recordingState {
case .idle:
// Check if there's existing transcript content
return meeting.transcriptChunks.isEmpty ? "Transcribe" : "Resume"
case .recording:
return "Stop"
}
}
func toggleRecording() {
switch recordingState {
case .idle:
startRecording()
case .recording:
stopRecording()
}
}
func startRecording() {
// Validate API key before starting recording
Task {
let validationResult = await APIKeyValidator.shared.validateCurrentAPIKey()
switch validationResult {
case .success():
// Key is valid, proceed with recording
audioManager.startRecording()
case .failure(let error):
// Show error message
errorMessage = error.localizedDescription
print("❌ API key validation failed: \(error.localizedDescription)")
}
}
}
func stopRecording() {
audioManager.stopRecording()
saveMeeting()
// Auto-generate notes if there's a transcript and no existing notes
if !meeting.formattedTranscript.isEmpty && meeting.generatedNotes.isEmpty {
print("🤖 Auto-generating notes after recording stopped")
// Switch to enhanced notes tab immediately when starting generation
selectedTab = .enhancedNotes
Task {
await generateNotes()
}
}
}
func loadTemplates() {
templates = LocalStorageManager.shared.loadTemplates()
// Load per-meeting template or default to Standard Meeting
if let meetingTemplateId = meeting.templateId {
selectedTemplateId = meetingTemplateId
} else if let defaultTemplate = templates.first(where: { $0.title == "Standard Meeting" }) {
selectedTemplateId = defaultTemplate.id
}
}
func generateNotes() async {
isGeneratingNotes = true
errorMessage = nil
// Clear any audio manager errors as well
audioManager.errorMessage = nil
// Clear existing notes for streaming
meeting.generatedNotes = ""
// Load settings for generation
let userBlurb = UserDefaultsManager.shared.userBlurb
let systemPrompt = UserDefaultsManager.shared.systemPrompt
// Use streaming generation
let stream = NotesGenerator.shared.generateNotesStream(
meeting: meeting,
userBlurb: userBlurb,
systemPrompt: systemPrompt,
templateId: selectedTemplateId
)
var hasError = false
for await result in stream {
switch result {
case .content(let chunk):
meeting.generatedNotes += chunk
case .error(let error):
errorMessage = error
hasError = true
print("🚨 Note Generation Error: \(error)")
break
}
}
// Only save if there was no error
if !hasError {
saveMeeting()
}
isGeneratingNotes = false
}
func saveMeeting() {
if isDeleted { return }
print("💾 Saving meeting: \(meeting.id)")
let success = LocalStorageManager.shared.saveMeeting(meeting)
print("💾 Save result: \(success ? "SUCCESS" : "FAILED")")
if success {
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
}
}
func copyCurrentTabContent() {
NSPasteboard.general.clearContents()
let content: String
switch selectedTab {
case .myNotes:
content = meeting.userNotes
case .transcript:
content = meeting.formattedTranscript
case .enhancedNotes:
var enhancedContent = ""
// Add title as h1 header if title is set
if !meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
enhancedContent += "# \(meeting.title)\n\n"
}
// Add the generated notes
enhancedContent += meeting.generatedNotes
// Add attribution footer
if !enhancedContent.isEmpty {
enhancedContent += "\n\n---\n\nNotes generated using [Meetingnotes](https://meetingnotes.owengretzinger.com), the free, open source AI notetaker."
}
content = enhancedContent
}
NSPasteboard.general.setString(content, forType: .string)
}
func deleteMeeting() {
let success = LocalStorageManager.shared.deleteMeeting(meeting)
if success {
isDeleted = true
NotificationCenter.default.post(name: .meetingDeleted, object: meeting)
}
}
func deleteIfEmpty() {
if isEmpty {
print("🗑️ Auto-deleting empty meeting")
deleteMeeting()
} else {
saveMeeting()
}
}
}