Compare commits

...
10 Commits
16 changed files with 312 additions and 119 deletions
+2 -2
View File
@@ -35,8 +35,8 @@ jobs:
| grep -q 'com.apple.security.network.server'
- name: Smoke test local API
run: |
defaults write owen.meetingnotes muteDeckAPIEnabled -bool true
defaults write owen.meetingnotes muteDeckAPIPort -int 19880
defaults write net.jamesbone.meetingnotes muteDeckAPIEnabled -bool true
defaults write net.jamesbone.meetingnotes muteDeckAPIPort -int 19880
"$RUNNER_TEMP/DerivedData/Build/Products/Release/Meetingnotes.app/Contents/MacOS/Meetingnotes" >"$RUNNER_TEMP/meetingnotes.log" 2>&1 &
app_pid=$!
trap 'kill "$app_pid" 2>/dev/null || true' EXIT
+4 -4
View File
@@ -276,7 +276,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 15;
CURRENT_PROJECT_VERSION = 22;
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.3;
MARKETING_VERSION = 1.1.10;
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 = 15;
CURRENT_PROJECT_VERSION = 22;
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.3;
MARKETING_VERSION = 1.1.10;
ONLY_ACTIVE_ARCH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
+63 -17
View File
@@ -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()
@@ -270,6 +270,19 @@ class LocalStorageManager {
return templates.sorted { $0.title < $1.title }
}
func preferredTemplateID(in templates: [NoteTemplate]? = nil) -> UUID? {
let availableTemplates = templates ?? loadTemplates()
if let selectedTemplateID = UserDefaultsManager.shared.selectedTemplateId,
availableTemplates.contains(where: { $0.id == selectedTemplateID }) {
return selectedTemplateID
}
let fallbackTemplateID = availableTemplates.first(where: { $0.title == "Standard Meeting" })?.id
?? availableTemplates.first?.id
UserDefaultsManager.shared.selectedTemplateId = fallbackTemplateID
return fallbackTemplateID
}
/// Deletes a template from local storage
/// - Parameter template: The template to delete
/// - Returns: True if successful, false otherwise
+1 -1
View File
@@ -38,7 +38,7 @@ struct MeetingnotesApp: App {
ContentView()
.frame(minWidth: 700, minHeight: 400)
}
.windowResizability(.contentSize)
.windowResizability(.automatic)
.defaultSize(width: 1000, height: 600)
.commands {
CommandGroup(after: .appInfo) {
+6 -2
View File
@@ -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
])
+21 -4
View File
@@ -244,7 +244,7 @@ private final class LocalRecordingController {
return statusPayload()
}
let meeting = Meeting()
let meeting = Meeting(templateId: LocalStorageManager.shared.preferredTemplateID())
guard LocalStorageManager.shared.saveMeeting(meeting) else {
throw LocalRecordingError.saveFailed
}
@@ -288,9 +288,7 @@ private final class LocalRecordingController {
meeting.transcriptChunks = chunks
let templates = LocalStorageManager.shared.loadTemplates()
if meeting.templateId == nil {
meeting.templateId = UserDefaultsManager.shared.selectedTemplateId
?? templates.first(where: { $0.title == "Standard Meeting" })?.id
?? templates.first?.id
meeting.templateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
}
_ = LocalStorageManager.shared.saveMeeting(meeting)
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
@@ -311,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)
}
@@ -106,6 +106,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
func isConfigured() -> Bool {
@@ -68,7 +68,7 @@ class MeetingListViewModel: ObservableObject {
}
func createNewMeeting() -> Meeting {
let newMeeting = Meeting()
let newMeeting = Meeting(templateId: LocalStorageManager.shared.preferredTemplateID())
meetings.insert(newMeeting, at: 0)
_ = LocalStorageManager.shared.saveMeeting(newMeeting)
// Track meeting creation event
+25 -25
View File
@@ -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
@@ -228,11 +216,13 @@ class MeetingViewModel: ObservableObject {
func loadTemplates() {
templates = LocalStorageManager.shared.loadTemplates()
// Load per-meeting template or default to Standard Meeting
if let meetingTemplateId = meeting.templateId {
// 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 if let defaultTemplate = templates.first(where: { $0.title == "Standard Meeting" }) {
selectedTemplateId = defaultTemplate.id
} else {
selectedTemplateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
meeting.templateId = selectedTemplateId
}
}
@@ -270,12 +260,30 @@ 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 }
print("💾 Saving meeting: \(meeting.id)")
@@ -331,12 +339,4 @@ class MeetingViewModel: ObservableObject {
}
}
func deleteIfEmpty() {
if isEmpty && !isRecording && !isProcessing {
print("🗑️ Auto-deleting empty meeting")
deleteMeeting()
} else {
saveMeeting()
}
}
}
@@ -52,24 +52,7 @@ class SettingsViewModel: ObservableObject {
func loadTemplates() {
templates = LocalStorageManager.shared.loadTemplates()
// Validate that the selected template still exists
if let selectedId = settings.selectedTemplateId {
if !templates.contains(where: { $0.id == selectedId }) {
// Selected template was deleted, clear the selection
settings.selectedTemplateId = nil
}
}
// If no template is selected, select the first default template
if settings.selectedTemplateId == nil {
if let defaultTemplate = templates.first(where: { $0.title == "Standard Meeting" }) {
settings.selectedTemplateId = defaultTemplate.id
} else if let firstTemplate = templates.first {
// Fallback to first available template
settings.selectedTemplateId = firstTemplate.id
}
}
settings.selectedTemplateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
}
func saveSettings(showMessage: Bool = true) {
@@ -4,6 +4,7 @@ import SwiftUI
@MainActor
class TemplatesViewModel: ObservableObject {
@Published var templates: [NoteTemplate] = []
@Published private(set) var defaultTemplateID: UUID? = nil
@Published var isLoading = false
@Published var errorMessage: String?
@@ -14,9 +15,15 @@ class TemplatesViewModel: ObservableObject {
func loadTemplates() {
isLoading = true
templates = LocalStorageManager.shared.loadTemplates()
defaultTemplateID = LocalStorageManager.shared.preferredTemplateID(in: templates)
isLoading = false
}
func setDefaultTemplate(_ template: NoteTemplate) {
UserDefaultsManager.shared.selectedTemplateId = template.id
defaultTemplateID = template.id
}
func saveTemplate(_ template: NoteTemplate) {
if LocalStorageManager.shared.saveTemplate(template) {
loadTemplates()
+5 -4
View File
@@ -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()
}
}
+6
View File
@@ -145,6 +145,12 @@ struct SettingsView: View {
.font(.caption)
.foregroundColor(.secondary)
Picker("Default template", selection: $viewModel.settings.selectedTemplateId) {
ForEach(viewModel.templates) { template in
Text(template.title).tag(Optional(template.id))
}
}
Button {
navigationPath.append("templates")
} label: {
+31 -28
View File
@@ -36,13 +36,13 @@ struct TemplateEditView: View {
TextEditor(text: $template.context)
.scrollContentBackground(.hidden)
.padding(8)
.background(Color.gray.opacity(0.05))
.cornerRadius(8)
.frame(minHeight: 100)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
.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
@@ -55,14 +55,12 @@ struct TemplateEditView: View {
Spacer()
Button {
withAnimation {
template.sections.append(
TemplateSection(
title: "New Section",
description: "Description of this section"
)
template.sections.append(
TemplateSection(
title: "New Section",
description: "Description of this section"
)
}
)
} label: {
HStack(spacing: 4) {
Image(systemName: "plus")
@@ -77,29 +75,27 @@ struct TemplateEditView: View {
.font(.caption)
.foregroundColor(.secondary)
ForEach(template.sections.indices, id: \.self) { index in
ForEach($template.sections) { $section in
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 8) {
TextField("Section Title", text: $template.sections[index].title)
TextField("Section Title", text: $section.title)
.textFieldStyle(.roundedBorder)
TextEditor(text: $template.sections[index].description)
TextEditor(text: $section.description)
.scrollContentBackground(.hidden)
.padding(8)
.background(Color.gray.opacity(0.05))
.cornerRadius(8)
.frame(minHeight: 60)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
.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 {
withAnimation {
let _ = template.sections.remove(at: index)
}
template.sections.removeAll { $0.id == section.id }
} label: {
Image(systemName: "trash")
.foregroundColor(.red)
@@ -131,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 {
+39 -8
View File
@@ -2,21 +2,21 @@ import SwiftUI
struct TemplateListView: View {
@StateObject private var viewModel = TemplatesViewModel()
@Environment(\.dismiss) private var dismiss
@State private var editingTemplate: NoteTemplate?
var body: some View {
List {
ForEach(viewModel.templates) { template in
HStack {
NavigationLink(destination: TemplateEditView(template: template) { updatedTemplate in
viewModel.saveTemplate(updatedTemplate)
}) {
Button {
presentEditor(for: template)
} label: {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(template.title)
.font(.headline)
if template.isDefault {
Text("Default")
Text("Built-in")
.font(.caption)
.padding(.horizontal, 6)
.padding(.vertical, 2)
@@ -36,9 +36,19 @@ struct TemplateListView: View {
}
.padding(.vertical, 4)
}
.buttonStyle(.plain)
Spacer()
Button {
viewModel.setDefaultTemplate(template)
} label: {
Image(systemName: viewModel.defaultTemplateID == template.id ? "star.fill" : "star")
.foregroundColor(viewModel.defaultTemplateID == template.id ? .accentColor : .secondary)
}
.buttonStyle(.plain)
.help(viewModel.defaultTemplateID == template.id ? "Default template" : "Use as default template")
if !template.isDefault {
Button(role: .destructive) {
viewModel.deleteTemplate(template)
@@ -50,6 +60,14 @@ struct TemplateListView: View {
}
}
.contextMenu {
if viewModel.defaultTemplateID != template.id {
Button {
viewModel.setDefaultTemplate(template)
} label: {
Label("Use as Default", systemImage: "star")
}
}
if !template.isDefault {
Button(role: .destructive) {
viewModel.deleteTemplate(template)
@@ -70,9 +88,9 @@ struct TemplateListView: View {
.navigationTitle("Note Templates")
.toolbar {
ToolbarItem(placement: .primaryAction) {
NavigationLink(destination: TemplateEditView(template: viewModel.createNewTemplate()) { updatedTemplate in
viewModel.saveTemplate(updatedTemplate)
}) {
Button {
presentEditor(for: viewModel.createNewTemplate())
} label: {
Image(systemName: "plus")
}
}
@@ -89,6 +107,19 @@ struct TemplateListView: View {
} message: {
Text(viewModel.errorMessage ?? "")
}
.sheet(item: $editingTemplate) { template in
NavigationStack {
TemplateEditView(template: template) { updatedTemplate in
viewModel.saveTemplate(updatedTemplate)
}
}
.frame(minWidth: 680, minHeight: 620)
}
}
private func presentEditor(for template: NoteTemplate) {
guard editingTemplate == nil else { return }
editingTemplate = template
}
}