Files
meetingnotes/meetingnotes/ViewModels/MeetingViewModel.swift
T

409 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = "Meeting Notes"
}
@MainActor
class MeetingViewModel: ObservableObject {
@Published var meeting: Meeting
@Published var isGeneratingNotes = false
@Published var errorMessage: String?
@Published private var recordingStateChanged = false // Trigger SwiftUI updates
@Published var isValidatingKey = false // Indicates API key validation in progress
@Published var isStartingRecording = false // Indicates recording start in progress
@Published var isRetryingTranscription = false
@Published private(set) var recoveryAudioFolderURL: URL?
// Computed property to determine if Generate button should animate
var shouldAnimateGenerateButton: Bool {
let generateButtonEnabled = !meeting.transcript.isEmpty && !isGeneratingNotes && !isRecording && !isProcessing && !isStartingRecording
let noEnhancedNotesYet = meeting.generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
return generateButtonEnabled && noEnhancedNotesYet
}
// Computed property to determine if the record button should animate
var shouldAnimateTranscribeButton: Bool {
return !isRecording && !isProcessing && meeting.transcriptChunks.isEmpty && !isStartingRecording
}
// Computed property that always uses the direct RecordingSessionManager check
var isRecording: Bool {
return recordingSessionManager.isRecordingMeeting(meeting.id)
}
var isProcessing: Bool {
return isRetryingTranscription ||
(recordingSessionManager.isProcessing && recordingSessionManager.activeMeetingId == meeting.id)
}
var canRetryTranscription: Bool {
recoveryAudioFolderURL != nil &&
!isRecording && !isProcessing && !isStartingRecording && !isValidatingKey
}
@Published var selectedTab: MeetingViewTab = .transcript // Default to transcript tab
@Published var isDeleted = false
@Published var templates: [NoteTemplate] = []
@Published var selectedTemplateId: UUID?
private let recordingSessionManager = RecordingSessionManager.shared
private var cancellables = Set<AnyCancellable>()
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
}
// Set initial tab based on notes existence
if !self.meeting.generatedNotes.isEmpty {
selectedTab = .enhancedNotes
} else {
selectedTab = .transcript
}
// Load templates and selected template
loadTemplates()
refreshRecoveryAudioFolder()
// 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)
// Trigger SwiftUI updates when recording state changes
Publishers.CombineLatest(recordingSessionManager.$isRecording, recordingSessionManager.$activeMeetingId)
.sink { [weak self] (isRecording, activeMeetingId) in
guard let self = self else { return }
// If recording started for this meeting, end starting state
if isRecording && activeMeetingId == self.meeting.id {
self.isStartingRecording = false
}
// Toggle the dummy property to trigger SwiftUI re-render
self.recordingStateChanged.toggle()
}
.store(in: &cancellables)
recordingSessionManager.$isProcessing
.sink { [weak self] _ in
self?.recordingStateChanged.toggle()
}
.store(in: &cancellables)
// Update error message when recording session manager encounters errors
recordingSessionManager.$errorMessage
.compactMap { $0 }
.sink { [weak self] errorMessage in
// Suppress non-critical, self-healing errors that should not distract the user
let lowercased = errorMessage.lowercased()
if errorMessage == ErrorMessage.sessionExpired || lowercased.contains("socket is not connected") {
print("️ Suppressed non-critical error: \(errorMessage)")
return
}
self?.errorMessage = errorMessage
print("🚨 Recording Session Manager Error: \(errorMessage)")
}
.store(in: &cancellables)
// If currently recording this meeting, load live transcript chunks
if recordingSessionManager.isRecordingMeeting(meeting.id) {
self.meeting.transcriptChunks = recordingSessionManager.getTranscriptChunks(for: meeting.id)
}
// Listen for final transcript updates for this meeting.
recordingSessionManager.$activeRecordingTranscriptChunksUpdated
.dropFirst()
.sink { [weak self] updatedChunks in
guard let self = self else { return }
// Stop publishes the final chunks after capture has ended but
// before the active meeting is cleared.
if recordingSessionManager.activeMeetingId == self.meeting.id {
self.meeting.transcriptChunks = updatedChunks
}
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .meetingSaved)
.compactMap { $0.object as? Meeting }
.filter { [weak self] in $0.id == self?.meeting.id }
.sink { [weak self] savedMeeting in
guard let self, !self.isDeleted, self.meeting != savedMeeting else { return }
self.meeting = savedMeeting
self.refreshRecoveryAudioFolder()
}
.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)
}
var recordingButtonText: String {
if isProcessing {
return "Processing"
}
if isRecording {
return "Stop"
} else {
// Check if there's existing transcript content
return meeting.transcriptChunks.isEmpty ? "Record" : "Resume"
}
}
func toggleRecording() {
// Prevent duplicate actions while validating API key or starting recording
if isValidatingKey || isStartingRecording || isProcessing { return }
// Use the same computed isRecording property for perfect consistency
if isRecording {
stopRecording()
} else {
startRecording()
}
}
func startRecording() {
// Validate API key before starting recording
isValidatingKey = true
isStartingRecording = true
Task {
let validationResult = await CoderAPIValidator.shared.validateCurrentAPIKey()
defer { isValidatingKey = false }
switch validationResult {
case .success():
// Key is valid, proceed with recording
recordingSessionManager.startRecording(for: meeting.id)
case .failure(let error):
// Show error message
errorMessage = error.localizedDescription
// Cancel starting if validation failed
isStartingRecording = false
print("❌ API key validation failed: \(error.localizedDescription)")
}
}
}
func stopRecording() {
isStartingRecording = true
Task {
let chunks = await recordingSessionManager.stopRecording()
meeting.transcriptChunks = chunks
meeting.recoveryAudioFolderName = recordingSessionManager.lastRecoveryAudioFolderName
meeting.transcriptionError = recordingSessionManager.errorMessage
refreshRecoveryAudioFolder()
saveMeeting()
if !meeting.formattedTranscript.isEmpty {
await generateNotes()
}
isStartingRecording = false
}
}
func retryTranscription() {
guard let recoveryAudioFolderURL, canRetryTranscription else { return }
isRetryingTranscription = true
errorMessage = nil
Task {
defer { isRetryingTranscription = false }
do {
let chunks = try await AudioManager.shared.transcribeRecoveryAudio(
in: recoveryAudioFolderURL,
captureStartedAt: meeting.date
)
meeting.transcriptChunks = chunks
meeting.recoveryAudioFolderName = recoveryAudioFolderURL.lastPathComponent
meeting.transcriptionError = nil
selectedTab = .transcript
guard saveMeeting() else {
throw CocoaError(.fileWriteUnknown)
}
await generateNotes()
} catch {
errorMessage = error.localizedDescription
meeting.transcriptionError = error.localizedDescription
_ = saveMeeting()
print("Retry transcription failed: \(error)")
}
}
}
private func refreshRecoveryAudioFolder() {
recoveryAudioFolderURL = LocalStorageManager.shared.findRecoveryAudioFolder(for: meeting)
meeting.recoveryAudioFolderName = recoveryAudioFolderURL?.lastPathComponent
}
func showAudioInFinder() {
guard let recoveryAudioFolderURL else { return }
LocalStorageManager.shared.showAudioFolderInFinder(recoveryAudioFolderURL)
}
func loadTemplates() {
templates = LocalStorageManager.shared.loadTemplates()
// Keep an existing meeting's template, otherwise use the configured default.
if let meetingTemplateId = meeting.templateId,
templates.contains(where: { $0.id == meetingTemplateId }) {
selectedTemplateId = meetingTemplateId
} else {
selectedTemplateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
meeting.templateId = selectedTemplateId
}
}
func generateNotes() async {
isGeneratingNotes = true
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 {
await generateTitleIfNeeded()
saveMeeting()
}
isGeneratingNotes = false
}
private func generateTitleIfNeeded() async {
guard meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
do {
if let title = try await NotesGenerator.shared.generateMeetingTitle(
meeting: meeting,
generatedNotes: meeting.generatedNotes,
templateId: selectedTemplateId
) {
meeting.title = title
}
} catch {
// A title is helpful but should never prevent completed notes from being saved.
print("Meeting title generation failed: \(error)")
}
}
@discardableResult
func saveMeeting() -> Bool {
if isDeleted { return false }
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)
}
return success
}
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() {
// If this meeting is currently being recorded, stop the recording first
if recordingSessionManager.isRecordingMeeting(meeting.id) {
print("🛑 Stopping recording for meeting being deleted: \(meeting.id)")
recordingSessionManager.cancelRecording()
}
let success = LocalStorageManager.shared.deleteMeeting(meeting)
if success {
isDeleted = true
NotificationCenter.default.post(name: .meetingDeleted, object: meeting)
}
}
}