Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f992457cfe | ||
|
|
ad995b59ad | ||
|
|
a13153b8fe | ||
|
|
0008bd3081 | ||
|
|
6bcdd51201 | ||
|
|
ea44104458 | ||
|
|
336fb95b07 |
@@ -276,7 +276,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = G9LVHZAJNX;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -290,7 +290,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 1.1.5;
|
||||
MARKETING_VERSION = 1.1.11;
|
||||
ONLY_ACTIVE_ARCH = NO;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
|
||||
@@ -312,7 +312,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = G9LVHZAJNX;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -326,7 +326,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 1.1.5;
|
||||
MARKETING_VERSION = 1.1.11;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
|
||||
@@ -183,9 +183,13 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported microphone format"])
|
||||
}
|
||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in
|
||||
guard let self, buffer.frameLength > 0 else { return }
|
||||
self.updateAudioLevel(buffer, source: .mic)
|
||||
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic)
|
||||
guard let self else { return }
|
||||
self.processAudioBuffer(
|
||||
{ buffer },
|
||||
converter: converter,
|
||||
targetFormat: targetFormat,
|
||||
source: .mic
|
||||
)
|
||||
}
|
||||
audioEngine.prepare()
|
||||
try audioEngine.start()
|
||||
@@ -298,28 +302,75 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
private func startTapIO(_ tap: ProcessTap) throws {
|
||||
guard var description = tap.tapStreamDescription,
|
||||
let inputFormat = AVAudioFormat(streamDescription: &description),
|
||||
let targetFormat = systemAudioFile?.processingFormat,
|
||||
let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else {
|
||||
let targetFormat = systemAudioFile?.processingFormat else {
|
||||
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported system audio format"])
|
||||
}
|
||||
try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in
|
||||
guard let self,
|
||||
let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, bufferListNoCopy: inputData, deallocator: nil),
|
||||
buffer.frameLength > 0 else { return }
|
||||
self.updateAudioLevel(buffer, source: .system)
|
||||
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .system)
|
||||
let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else { return }
|
||||
self.processAudioBuffer(
|
||||
{ self.copyAudioBuffer(from: inputData, format: inputFormat) },
|
||||
converter: converter,
|
||||
targetFormat: targetFormat,
|
||||
source: .system
|
||||
)
|
||||
} invalidationHandler: { [weak self] _ in
|
||||
guard let self, !self.isRestartingSystemTap, self.isRecording else { return }
|
||||
Task { await self.restartSystemAudioTap() }
|
||||
}
|
||||
}
|
||||
|
||||
private func copyAudioBuffer(
|
||||
from inputData: UnsafePointer<AudioBufferList>,
|
||||
format: AVAudioFormat
|
||||
) -> AVAudioPCMBuffer? {
|
||||
guard let borrowedBuffer = AVAudioPCMBuffer(
|
||||
pcmFormat: format,
|
||||
bufferListNoCopy: inputData,
|
||||
deallocator: nil
|
||||
), borrowedBuffer.frameLength > 0,
|
||||
let ownedBuffer = AVAudioPCMBuffer(
|
||||
pcmFormat: format,
|
||||
frameCapacity: borrowedBuffer.frameLength
|
||||
) else { return nil }
|
||||
|
||||
ownedBuffer.frameLength = borrowedBuffer.frameLength
|
||||
let sourceBuffers = UnsafeMutableAudioBufferListPointer(
|
||||
UnsafeMutablePointer(mutating: inputData)
|
||||
)
|
||||
let destinationBuffers = UnsafeMutableAudioBufferListPointer(
|
||||
ownedBuffer.mutableAudioBufferList
|
||||
)
|
||||
guard sourceBuffers.count == destinationBuffers.count else { return nil }
|
||||
|
||||
for index in 0..<sourceBuffers.count {
|
||||
let source = sourceBuffers[index]
|
||||
let destination = destinationBuffers[index]
|
||||
let byteCount = Int(source.mDataByteSize)
|
||||
guard byteCount <= Int(destination.mDataByteSize),
|
||||
let sourceData = source.mData,
|
||||
let destinationData = destination.mData else { return nil }
|
||||
memcpy(destinationData, sourceData, byteCount)
|
||||
destinationBuffers[index].mDataByteSize = source.mDataByteSize
|
||||
}
|
||||
return ownedBuffer
|
||||
}
|
||||
|
||||
private func processAudioBuffer(
|
||||
_ inputBuffer: AVAudioPCMBuffer,
|
||||
_ inputBufferProvider: () -> AVAudioPCMBuffer?,
|
||||
converter: AVAudioConverter,
|
||||
targetFormat: AVAudioFormat,
|
||||
source: AudioSource
|
||||
) {
|
||||
// The system callback copies its borrowed Core Audio memory while this
|
||||
// lock prevents teardown, then conversion operates on the owned copy.
|
||||
audioFileLock.lock()
|
||||
defer { audioFileLock.unlock() }
|
||||
guard isAcceptingAudio,
|
||||
let inputBuffer = inputBufferProvider(),
|
||||
inputBuffer.frameLength > 0 else { return }
|
||||
|
||||
updateAudioLevel(inputBuffer, source: source)
|
||||
let ratio = targetFormat.sampleRate / inputBuffer.format.sampleRate
|
||||
let capacity = max(1, AVAudioFrameCount(ceil(Double(inputBuffer.frameLength) * ratio)))
|
||||
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return }
|
||||
@@ -336,11 +387,6 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
}
|
||||
guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return }
|
||||
|
||||
audioFileLock.lock()
|
||||
defer { audioFileLock.unlock() }
|
||||
guard isAcceptingAudio else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
switch source {
|
||||
case .mic:
|
||||
@@ -378,8 +424,8 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
pendingMicRestart = nil
|
||||
AudioLevelManager.shared.updateRecordingState(false)
|
||||
|
||||
// Stop new writes and wait for any callback already writing before
|
||||
// AVAudioFile is finalized and released.
|
||||
// Stop new callbacks and wait for any active conversion/write before
|
||||
// invalidating callback-owned buffers or finalizing AVAudioFile.
|
||||
audioFileLock.lock()
|
||||
isAcceptingAudio = false
|
||||
audioFileLock.unlock()
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import Sparkle
|
||||
import PostHog
|
||||
|
||||
@main
|
||||
struct MeetingnotesApp: App {
|
||||
private let updaterController: SPUStandardUpdaterController
|
||||
@StateObject private var recordingSessionManager = RecordingSessionManager.shared
|
||||
|
||||
init() {
|
||||
updaterController = SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil)
|
||||
@@ -34,9 +36,10 @@ struct MeetingnotesApp: App {
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WindowGroup("Meetingnotes", id: "main") {
|
||||
ContentView()
|
||||
.frame(minWidth: 700, minHeight: 400)
|
||||
.background(MainWindowConfigurator())
|
||||
}
|
||||
.windowResizability(.automatic)
|
||||
.defaultSize(width: 1000, height: 600)
|
||||
@@ -45,6 +48,123 @@ struct MeetingnotesApp: App {
|
||||
CheckForUpdatesView(updater: updaterController.updater)
|
||||
}
|
||||
}
|
||||
|
||||
MenuBarExtra {
|
||||
MeetingnotesMenu(
|
||||
recordingSessionManager: recordingSessionManager,
|
||||
updater: updaterController.updater
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: recordingSessionManager.isRecording ? "record.circle.fill" : "waveform")
|
||||
.accessibilityLabel(recordingSessionManager.isRecording ? "Meetingnotes recording" : "Meetingnotes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MeetingnotesMenu: View {
|
||||
@ObservedObject var recordingSessionManager: RecordingSessionManager
|
||||
let updater: SPUUpdater
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
var body: some View {
|
||||
if recordingSessionManager.isRecording {
|
||||
Label("Recording", systemImage: "record.circle.fill")
|
||||
} else if recordingSessionManager.isProcessing {
|
||||
Label("Processing audio", systemImage: "hourglass")
|
||||
}
|
||||
|
||||
Button {
|
||||
MainWindowController.shared.show(using: openWindow)
|
||||
} label: {
|
||||
Label("Open Meetingnotes", systemImage: "macwindow")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button {
|
||||
updater.checkForUpdates()
|
||||
} label: {
|
||||
Label("Check for Updates...", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
.keyboardShortcut("u")
|
||||
|
||||
Button {
|
||||
NSApplication.shared.terminate(nil)
|
||||
} label: {
|
||||
Label("Quit Meetingnotes", systemImage: "power")
|
||||
}
|
||||
.keyboardShortcut("q")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MainWindowController: NSObject {
|
||||
static let shared = MainWindowController()
|
||||
|
||||
private weak var window: NSWindow?
|
||||
|
||||
func configure(_ window: NSWindow) {
|
||||
if self.window !== window {
|
||||
if let previousWindow = self.window {
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSWindow.willMiniaturizeNotification,
|
||||
object: previousWindow
|
||||
)
|
||||
}
|
||||
self.window = window
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(windowWillMiniaturize(_:)),
|
||||
name: NSWindow.willMiniaturizeNotification,
|
||||
object: window
|
||||
)
|
||||
}
|
||||
|
||||
if let minimizeButton = window.standardWindowButton(.miniaturizeButton) {
|
||||
minimizeButton.target = self
|
||||
minimizeButton.action = #selector(hideWindow(_:))
|
||||
}
|
||||
}
|
||||
|
||||
func show(using openWindow: OpenWindowAction) {
|
||||
if let window {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
} else {
|
||||
openWindow(id: "main")
|
||||
}
|
||||
NSApplication.shared.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
@objc private func hideWindow(_ sender: NSButton) {
|
||||
sender.window?.orderOut(nil)
|
||||
}
|
||||
|
||||
@objc private func windowWillMiniaturize(_ notification: Notification) {
|
||||
guard let window = notification.object as? NSWindow else { return }
|
||||
DispatchQueue.main.async {
|
||||
window.deminiaturize(nil)
|
||||
window.orderOut(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MainWindowConfigurator: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
configureWindow(for: view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
configureWindow(for: nsView)
|
||||
}
|
||||
|
||||
private func configureWindow(for view: NSView) {
|
||||
DispatchQueue.main.async {
|
||||
guard let window = view.window else { return }
|
||||
MainWindowController.shared.configure(window)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,11 @@ final class CoderAPIClient {
|
||||
)
|
||||
}
|
||||
|
||||
func streamChat(systemPrompt: String, model: String) -> AsyncThrowingStream<String, Error> {
|
||||
func streamChat(
|
||||
systemPrompt: String,
|
||||
userPrompt: String = "Create the meeting notes now.",
|
||||
model: String
|
||||
) -> AsyncThrowingStream<String, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
Task {
|
||||
do {
|
||||
@@ -113,7 +117,7 @@ final class CoderAPIClient {
|
||||
"model": selectedModel,
|
||||
"messages": [
|
||||
["role": "system", "content": systemPrompt],
|
||||
["role": "user", "content": "Create the meeting notes now."]
|
||||
["role": "user", "content": userPrompt]
|
||||
],
|
||||
"stream": true
|
||||
])
|
||||
|
||||
@@ -309,8 +309,27 @@ private final class LocalRecordingController {
|
||||
generatedNotes = ""
|
||||
}
|
||||
}
|
||||
var meetingChanged = false
|
||||
if !generatedNotes.isEmpty {
|
||||
meeting.generatedNotes = generatedNotes
|
||||
meetingChanged = true
|
||||
}
|
||||
if meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
do {
|
||||
if let title = try await NotesGenerator.shared.generateMeetingTitle(
|
||||
meeting: meeting,
|
||||
generatedNotes: generatedNotes,
|
||||
templateId: meeting.templateId
|
||||
) {
|
||||
meeting.title = title
|
||||
meetingChanged = true
|
||||
}
|
||||
} catch {
|
||||
// The recording and generated notes remain valid without a generated title.
|
||||
print("Meeting title generation failed: \(error)")
|
||||
}
|
||||
}
|
||||
if meetingChanged {
|
||||
_ = LocalStorageManager.shared.saveMeeting(meeting)
|
||||
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
|
||||
}
|
||||
|
||||
@@ -105,6 +105,88 @@ class NotesGenerator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a short title after meeting notes have been created.
|
||||
func generateMeetingTitle(
|
||||
meeting: Meeting,
|
||||
generatedNotes: String,
|
||||
templateId: UUID?
|
||||
) async throws -> String? {
|
||||
let templates = LocalStorageManager.shared.loadTemplates()
|
||||
let template = templateId.flatMap { id in
|
||||
templates.first(where: { $0.id == id })
|
||||
}
|
||||
let meetingContext: String
|
||||
if let template {
|
||||
meetingContext = "\(template.title): \(template.context)"
|
||||
} else {
|
||||
meetingContext = "General meeting"
|
||||
}
|
||||
|
||||
let trimmedNotes = generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let meetingContent = trimmedNotes.isEmpty ? meeting.formattedTranscript : trimmedNotes
|
||||
guard !meetingContent.isEmpty else { return nil }
|
||||
|
||||
let systemPrompt = """
|
||||
Create a concise, descriptive title for a recorded meeting from its context and content.
|
||||
Return only the title in 3 to 8 words. Do not use quotation marks, Markdown, or a trailing period.
|
||||
Avoid generic titles such as Meeting, Discussion, Meeting Notes, or Untitled Meeting.
|
||||
"""
|
||||
let userPrompt = """
|
||||
Meeting context:
|
||||
\(meetingContext)
|
||||
|
||||
Meeting content:
|
||||
\(meetingContent)
|
||||
"""
|
||||
|
||||
var generatedTitle = ""
|
||||
let stream = CoderAPIClient.shared.streamChat(
|
||||
systemPrompt: systemPrompt,
|
||||
userPrompt: userPrompt,
|
||||
model: UserDefaultsManager.shared.notesModel
|
||||
)
|
||||
for try await content in stream {
|
||||
generatedTitle += content
|
||||
}
|
||||
|
||||
return normalizedTitle(generatedTitle)
|
||||
}
|
||||
|
||||
private func normalizedTitle(_ value: String) -> String? {
|
||||
guard var title = value
|
||||
.split(whereSeparator: \Character.isNewline)
|
||||
.map(String.init)
|
||||
.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
while title.hasPrefix("#") {
|
||||
title.removeFirst()
|
||||
title = title.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
if title.lowercased().hasPrefix("title:") {
|
||||
title = String(title.dropFirst("title:".count))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
title = title.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
|
||||
if title.hasSuffix(".") {
|
||||
title.removeLast()
|
||||
}
|
||||
|
||||
if title.count > 80 {
|
||||
let prefix = String(title.prefix(80))
|
||||
if let lastSpace = prefix.lastIndex(of: " ") {
|
||||
title = String(prefix[..<lastSpace])
|
||||
} else {
|
||||
title = prefix
|
||||
}
|
||||
}
|
||||
|
||||
title = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return title.isEmpty ? nil : title
|
||||
}
|
||||
|
||||
/// Validates if the Coder service token is configured
|
||||
/// - Returns: True if API key exists, false otherwise
|
||||
@@ -115,4 +197,4 @@ class NotesGenerator {
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ extension Notification.Name {
|
||||
enum MeetingViewTab: String, CaseIterable {
|
||||
case myNotes = "My Notes"
|
||||
case transcript = "Transcript"
|
||||
case enhancedNotes = "Enhanced Notes"
|
||||
case enhancedNotes = "Meeting Notes"
|
||||
}
|
||||
|
||||
|
||||
@@ -54,15 +54,6 @@ class MeetingViewModel: ObservableObject {
|
||||
|
||||
private let recordingSessionManager = RecordingSessionManager.shared
|
||||
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
|
||||
@@ -76,9 +67,6 @@ class MeetingViewModel: ObservableObject {
|
||||
|
||||
|
||||
|
||||
// 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
|
||||
@@ -272,11 +260,29 @@ class MeetingViewModel: ObservableObject {
|
||||
|
||||
// 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)")
|
||||
}
|
||||
}
|
||||
|
||||
func saveMeeting() {
|
||||
if isDeleted { return }
|
||||
@@ -333,12 +339,4 @@ class MeetingViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func deleteIfEmpty() {
|
||||
if isEmpty && !isRecording && !isProcessing {
|
||||
print("🗑️ Auto-deleting empty meeting")
|
||||
deleteMeeting()
|
||||
} else {
|
||||
saveMeeting()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,7 +327,7 @@ struct MeetingDetailContentView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isProcessing || viewModel.isStartingRecording)
|
||||
.help("Generate enhanced notes using a template")
|
||||
.help("Generate meeting notes using a template")
|
||||
|
||||
// Recording Button
|
||||
Button(action: {
|
||||
@@ -374,7 +374,7 @@ struct MeetingDetailContentView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// Edit/Preview button (for My Notes and Enhanced Notes)
|
||||
// Edit/Preview button (for My Notes and Meeting Notes)
|
||||
if viewModel.selectedTab == .myNotes || viewModel.selectedTab == .enhancedNotes {
|
||||
Button(action: {
|
||||
isEditing.toggle()
|
||||
@@ -445,8 +445,9 @@ struct MeetingDetailContentView: View {
|
||||
Text("Are you sure you want to delete this meeting? This action cannot be undone.")
|
||||
}
|
||||
.onDisappear {
|
||||
// Auto-delete empty meetings when leaving, otherwise save
|
||||
viewModel.deleteIfEmpty()
|
||||
// A failed recording may still be empty. Keep it until the user
|
||||
// explicitly deletes it so app updates cannot erase history.
|
||||
viewModel.saveMeeting()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,16 @@ struct TemplateEditView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("Meeting Context", text: $template.context, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(4...10)
|
||||
TextEditor(text: $template.context)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(8)
|
||||
.frame(height: 140)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
// Sections
|
||||
@@ -75,9 +82,16 @@ struct TemplateEditView: View {
|
||||
TextField("Section Title", text: $section.title)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
TextField("Section Description", text: $section.description, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(3...8)
|
||||
TextEditor(text: $section.description)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(8)
|
||||
.frame(height: 90)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
Button {
|
||||
@@ -113,12 +127,19 @@ struct TemplateEditView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top)
|
||||
.disabled(template.title.isEmpty || template.sections.isEmpty)
|
||||
.disabled(!canSave)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.navigationTitle("Edit Template")
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
let hasTitle = !template.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
let hasInstructions = !template.context.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| !template.sections.isEmpty
|
||||
return hasTitle && hasInstructions
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
Reference in New Issue
Block a user