Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a13153b8fe | ||
|
|
0008bd3081 | ||
|
|
6bcdd51201 | ||
|
|
ea44104458 | ||
|
|
336fb95b07 | ||
|
|
c7e29828b4 | ||
|
|
97eb58d1dc | ||
|
|
bcc16cd53f | ||
|
|
25ae1ae710 |
@@ -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
|
||||
|
||||
@@ -276,7 +276,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 15;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
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.9;
|
||||
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 = 21;
|
||||
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.9;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -269,6 +269,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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -68,11 +68,11 @@ 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
|
||||
PostHogSDK.shared.capture("meeting_created")
|
||||
return newMeeting
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,12 +321,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,8 +15,14 @@ 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) {
|
||||
@@ -42,4 +49,4 @@ class TemplatesViewModel: ObservableObject {
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,12 @@ struct SettingsView: View {
|
||||
Text("Create and manage note templates")
|
||||
.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")
|
||||
|
||||
@@ -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 {
|
||||
@@ -152,4 +155,4 @@ struct TemplateEditView: View {
|
||||
)
|
||||
) { _ in }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +36,18 @@ 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) {
|
||||
@@ -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,9 +107,22 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TemplateListView()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user