feat: note templates
This commit is contained in:
@@ -19,10 +19,11 @@ Implemented:
|
||||
- Use your own API key
|
||||
- Auto updates
|
||||
- Text formatting
|
||||
- Different note templates
|
||||
|
||||
Todo:
|
||||
|
||||
- Different note templates
|
||||
- Integrate with Posthog for anonymous analytics
|
||||
|
||||
Later:
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ class LocalStorageManager {
|
||||
|
||||
private let documentsDirectory: URL
|
||||
private let meetingsDirectory: URL
|
||||
private let templatesDirectory: URL
|
||||
|
||||
private init() {
|
||||
// Get the app's documents directory
|
||||
@@ -18,9 +19,14 @@ class LocalStorageManager {
|
||||
// Create meetings subdirectory
|
||||
meetingsDirectory = documentsDirectory.appendingPathComponent("Meetings")
|
||||
|
||||
// Ensure directory exists
|
||||
// Create templates subdirectory
|
||||
templatesDirectory = documentsDirectory.appendingPathComponent("Templates")
|
||||
|
||||
// Ensure directories exist
|
||||
try? FileManager.default.createDirectory(at: meetingsDirectory,
|
||||
withIntermediateDirectories: true)
|
||||
try? FileManager.default.createDirectory(at: templatesDirectory,
|
||||
withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
// MARK: - Meeting Management
|
||||
@@ -123,6 +129,98 @@ class LocalStorageManager {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Template Management
|
||||
|
||||
/// Saves a note template to local storage
|
||||
/// - Parameter template: The template to save
|
||||
/// - Returns: True if successful, false otherwise
|
||||
func saveTemplate(_ template: NoteTemplate) -> Bool {
|
||||
let fileURL = templatesDirectory.appendingPathComponent("\(template.id.uuidString).json")
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted]
|
||||
|
||||
let data = try encoder.encode(template)
|
||||
|
||||
// Write atomically using a temp file then replace
|
||||
let tmpURL = fileURL.appendingPathExtension("tmp")
|
||||
try data.write(to: tmpURL, options: .atomic)
|
||||
try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil)
|
||||
|
||||
print("✅ Saved template: \(template.id)")
|
||||
return true
|
||||
} catch {
|
||||
print("❌ Failed to save template: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads all templates from local storage
|
||||
/// - Returns: Array of templates, empty if none found
|
||||
func loadTemplates() -> [NoteTemplate] {
|
||||
var templates: [NoteTemplate] = []
|
||||
|
||||
do {
|
||||
let fileURLs = try FileManager.default.contentsOfDirectory(at: templatesDirectory,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: .skipsHiddenFiles)
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
for fileURL in fileURLs {
|
||||
guard fileURL.pathExtension == "json" else { continue }
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
let template = try decoder.decode(NoteTemplate.self, from: data)
|
||||
templates.append(template)
|
||||
print("✅ Loaded template: \(template.id)")
|
||||
} catch {
|
||||
print("❌ Failed to load template from \(fileURL): \(error)")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("❌ Failed to read templates directory: \(error)")
|
||||
}
|
||||
|
||||
// If no templates exist (user may have deleted the Templates folder),
|
||||
// automatically regenerate the built-in default templates. We keep
|
||||
// writing the flag so previous versions that still rely on it remain
|
||||
// functional, but we no longer gate the regeneration behind it.
|
||||
if templates.isEmpty {
|
||||
let defaultTemplates = NoteTemplate.defaultTemplates()
|
||||
for template in defaultTemplates {
|
||||
_ = saveTemplate(template)
|
||||
templates.append(template)
|
||||
}
|
||||
}
|
||||
|
||||
return templates.sorted { $0.title < $1.title }
|
||||
}
|
||||
|
||||
/// Deletes a template from local storage
|
||||
/// - Parameter template: The template to delete
|
||||
/// - Returns: True if successful, false otherwise
|
||||
func deleteTemplate(_ template: NoteTemplate) -> Bool {
|
||||
// Don't allow deletion of default templates
|
||||
if template.isDefault {
|
||||
print("⚠️ Cannot delete default template")
|
||||
return false
|
||||
}
|
||||
|
||||
let fileURL = templatesDirectory.appendingPathComponent("\(template.id.uuidString).json")
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
print("✅ Deleted template: \(template.id)")
|
||||
return true
|
||||
} catch {
|
||||
print("❌ Failed to delete template: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings Management
|
||||
|
||||
/// Saves non-sensitive settings to local storage
|
||||
|
||||
@@ -69,18 +69,20 @@ struct Meeting: Codable, Identifiable, Hashable {
|
||||
var transcriptChunks: [TranscriptChunk]
|
||||
var userNotes: String
|
||||
var generatedNotes: String
|
||||
var templateId: UUID? // Add property to track per-meeting template
|
||||
// MARK: - Data versioning
|
||||
/// Version of this Meeting record on disk. Useful for migration.
|
||||
var dataVersion: Int
|
||||
/// Current app data version. Increment whenever you make a breaking change to `Meeting` that requires migration.
|
||||
static let currentDataVersion = 1
|
||||
|
||||
init(id: UUID = UUID(),
|
||||
init(id: UUID = UUID(),
|
||||
date: Date = Date(),
|
||||
title: String = "",
|
||||
transcriptChunks: [TranscriptChunk] = [],
|
||||
userNotes: String = "",
|
||||
userNotes: String = "",
|
||||
generatedNotes: String = "",
|
||||
templateId: UUID? = nil,
|
||||
dataVersion: Int = Meeting.currentDataVersion) {
|
||||
self.id = id
|
||||
self.date = date
|
||||
@@ -88,6 +90,7 @@ struct Meeting: Codable, Identifiable, Hashable {
|
||||
self.transcriptChunks = transcriptChunks
|
||||
self.userNotes = userNotes
|
||||
self.generatedNotes = generatedNotes
|
||||
self.templateId = templateId
|
||||
self.dataVersion = dataVersion
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import Foundation
|
||||
|
||||
struct TemplateSection: Codable, Identifiable, Hashable {
|
||||
let id: UUID
|
||||
var title: String
|
||||
var description: String
|
||||
|
||||
init(id: UUID = UUID(), title: String, description: String) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.description = description
|
||||
}
|
||||
}
|
||||
|
||||
struct NoteTemplate: Codable, Identifiable, Hashable {
|
||||
let id: UUID
|
||||
var title: String
|
||||
var context: String
|
||||
var sections: [TemplateSection]
|
||||
var isDefault: Bool
|
||||
|
||||
init(id: UUID = UUID(), title: String, context: String, sections: [TemplateSection] = [], isDefault: Bool = false) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.context = context
|
||||
self.sections = sections
|
||||
self.isDefault = isDefault
|
||||
}
|
||||
|
||||
// Generate the template content for the system prompt
|
||||
var formattedContent: String {
|
||||
var content = title
|
||||
|
||||
// Add context
|
||||
if !context.isEmpty {
|
||||
content += ": \(context)\n\n"
|
||||
}
|
||||
|
||||
// Add sections header
|
||||
if !sections.isEmpty {
|
||||
for section in sections {
|
||||
content += "## \(section.title)\n\n[\(section.description)]\n\n"
|
||||
}
|
||||
}
|
||||
|
||||
return content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
// Default templates
|
||||
static func defaultTemplates() -> [NoteTemplate] {
|
||||
return [
|
||||
// Default template (current behavior)
|
||||
NoteTemplate(
|
||||
id: UUID(),
|
||||
title: "Standard Meeting",
|
||||
context: "I attended a meeting and want to capture the main discussion points, action items, decisions made, and any deadlines or follow-up tasks. Use \"Discussion Points\", \"Action Items\", \"Decisions Made\", and \"Deadlines & Follow-Up\" as the sections unless otherwise indicated by the user. Capture these meeting notes in a concise and actionable format.",
|
||||
// sections: [
|
||||
// TemplateSection(title: "Key Discussion Points", description: "The main topics and points discussed during the meeting"),
|
||||
// TemplateSection(title: "Action Items", description: "Tasks and responsibilities assigned to team members"),
|
||||
// TemplateSection(title: "Decisions Made", description: "Important decisions reached during the meeting"),
|
||||
// TemplateSection(title: "Deadlines and Follow-up", description: "Any deadlines or follow-up items that need attention")
|
||||
// ],
|
||||
isDefault: true
|
||||
),
|
||||
|
||||
// 1 to 1
|
||||
NoteTemplate(
|
||||
id: UUID(),
|
||||
title: "1 on 1",
|
||||
context: "I am having a 1:1 meeting with someone in my team, please capture these meeting notes in a concise and actionable format. Focus on immediate priorities, progress, challenges, and personal feedback, ensuring the notes are structured for clarity, efficiency and easy follow-up.",
|
||||
sections: [
|
||||
TemplateSection(title: "Top of mind", description: "What's the most pressing issue or priority? Capture the top concerns or focus areas that need immediate attention."),
|
||||
TemplateSection(title: "Updates and wins", description: "Highlight recent achievements and progress. What's going well? Document key updates that show momentum."),
|
||||
TemplateSection(title: "Challenges and blockers", description: "What obstacles are in the way? Note any blockers that are slowing progress."),
|
||||
TemplateSection(title: "Mutual feedback", description: "Did they give me any feedback on what I could do differently? Is there anything I should change about our team to make us more successful? Did I share any feedback for them? List it all here."),
|
||||
TemplateSection(title: "Next Milestone", description: "Define clear action items and next steps. Who's doing what by when? Ensure accountability and follow-up.")
|
||||
],
|
||||
isDefault: true
|
||||
),
|
||||
|
||||
// Customer: Discovery
|
||||
NoteTemplate(
|
||||
id: UUID(),
|
||||
title: "Customer Discovery",
|
||||
context: "I had a call with a potential customer. This call helps me to understand their needs, concerns, and goals, and ensure that I gather all the necessary information to follow up effectively. I'm interested in the details that might help me close a deal. Please pull out specific figures and helpful quotes. Focus only on what they say, not me.",
|
||||
sections: [
|
||||
TemplateSection(title: "Their background", description: "I care about key details about the client's business, industry, and role. This context helps me understand where they are coming from and what might be driving their needs for my product."),
|
||||
TemplateSection(title: "Pain points and needs", description: "Please highlight the specific challenges and needs they express. This section is crucial for understanding what problems they are trying to solve and what they are looking for in a solution."),
|
||||
TemplateSection(title: "Questions or concerns", description: "Capture any questions or concerns they raise during the meeting. This section ensures that I address their worries and provide relevant follow-up information."),
|
||||
TemplateSection(title: "Budget and timeline", description: "How much do they have to spend? Are there any key dates I should be aware of?"),
|
||||
TemplateSection(title: "Next Steps", description: "Outline the next steps based on our conversation. This could include scheduling another meeting, sending additional information, or any other follow-up actions needed to keep the conversation moving forward. Include any relevant dates and deadlines which are mentioned.")
|
||||
],
|
||||
isDefault: true
|
||||
),
|
||||
|
||||
// Hiring
|
||||
NoteTemplate(
|
||||
id: UUID(),
|
||||
title: "Hiring",
|
||||
context: "I met with a job candidate to assess their suitability for a position within our company.",
|
||||
sections: [
|
||||
TemplateSection(title: "Their background", description: "Detail the candidate's professional journey, education, and overall career progression. Include information about their current role and responsibilities, as well as any significant achievements or projects they've worked on."),
|
||||
TemplateSection(title: "Skills and experience", description: "Highlight the specific skills and experiences that are most relevant to the position. Focus on technical abilities, soft skills, and any particular areas of expertise that align with the job requirements."),
|
||||
TemplateSection(title: "Motivation and fit", description: "Include the candidate's career aspirations and why they're interested in this particular role and company."),
|
||||
TemplateSection(title: "Availability and salary expectations", description: "Note down the candidate's current notice period or earliest start date. Include their salary expectations and any other compensation-related questions."),
|
||||
TemplateSection(title: "My thoughts", description: "I may have written my thoughts in the raw notes, list them here. Otherwise, put N/A."),
|
||||
TemplateSection(title: "Next steps", description: "Write here any subsequent stages in the hiring process that I mention. Include any considerations regarding the candidate's availability or timelines that they mention.")
|
||||
],
|
||||
isDefault: true
|
||||
),
|
||||
|
||||
// Stand-Up
|
||||
NoteTemplate(
|
||||
id: UUID(),
|
||||
title: "Standup",
|
||||
context: "I attended a daily standup meeting. The goal is to document each participant's updates regarding their recent accomplishments, current focus, and any blockers they are facing. Keep these notes short and to-the-point.",
|
||||
sections: [
|
||||
TemplateSection(title: "Announcements", description: "Include any note-worthy points from the small-talk or announcements at the beginning of the call."),
|
||||
TemplateSection(title: "Updates", description: "Break these down into what was achieved yesterday, or accomplishments, what each person is working on today and highlight any blockers that could impact progress."),
|
||||
TemplateSection(title: "Sidebar", description: "Summarize any further discussions or issues that were explored after the main updates. Note any collaborative efforts, decisions made, or additional points raised."),
|
||||
TemplateSection(title: "Action Items", description: "Document and assign next steps from the meeting, summarize immediate tasks, provide reminders, and ensure accountability and clarity on responsibilities.")
|
||||
],
|
||||
isDefault: true
|
||||
),
|
||||
|
||||
// Weekly Team Meeting
|
||||
NoteTemplate(
|
||||
id: UUID(),
|
||||
title: "Weekly Team Meeting",
|
||||
context: "I met with my team to assess our project's health and align our efforts. My aim was to gain a clear understanding of our progress, address any emerging challenges, and ensure each team member is clear on their role in advancing our goals",
|
||||
sections: [
|
||||
TemplateSection(title: "Announcements", description: "Note here any significant announcements made, whether they relate to professional and company-wide updates, or important events in the personal lives of my colleagues."),
|
||||
TemplateSection(title: "Review of Progress", description: "Capture the discussion on the team's progress towards the overall strategic goals."),
|
||||
TemplateSection(title: "Key Achievements", description: "Summarize the notable achievements and results shared by team members, highlighting significant successes or completed tasks from the past week."),
|
||||
TemplateSection(title: "Challenges and Adjustments Needed", description: "Document any challenges the team is facing, including obstacles that have arisen. Note any adjustments or changes in strategy that were discussed to overcome these challenges."),
|
||||
TemplateSection(title: "Action Items and Accountability for the Week Ahead", description: "Record the action items assigned for the upcoming week, including who is responsible for each task and any deadlines or accountability measures that were agreed upon.")
|
||||
],
|
||||
isDefault: true
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ struct Settings: Codable {
|
||||
var openAIKey: String
|
||||
var userBlurb: String
|
||||
var systemPrompt: String
|
||||
var selectedTemplateId: UUID?
|
||||
|
||||
// System prompt default loading
|
||||
static func defaultSystemPrompt() -> String {
|
||||
@@ -34,11 +35,11 @@ struct Settings: Codable {
|
||||
|
||||
init(openAIKey: String = "",
|
||||
userBlurb: String = "",
|
||||
systemPrompt: String = "") {
|
||||
systemPrompt: String = "",
|
||||
selectedTemplateId: UUID? = nil) {
|
||||
self.openAIKey = openAIKey
|
||||
self.userBlurb = userBlurb
|
||||
self.systemPrompt = systemPrompt.isEmpty ? Settings.defaultSystemPrompt() : systemPrompt
|
||||
self.selectedTemplateId = selectedTemplateId
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -7,23 +7,18 @@ Here are important instructions:
|
||||
|
||||
<important>
|
||||
You will be provided with:
|
||||
- a template that specifies the format and sections for the meeting notes
|
||||
- a transcript of the meeting
|
||||
- information the user provided as additional context
|
||||
- any additional notes the user manually noted down
|
||||
|
||||
Your enhanced meeting notes should include the following sections:
|
||||
- the key discussion points
|
||||
- action items
|
||||
- decisions made
|
||||
- any deadlines or follow-up required
|
||||
|
||||
Include only these section headers, unless the user requests otherwise (see formatting_requests section).
|
||||
Include only the section headers specified in the template, unless the user requests otherwise (see formatting_requests section).
|
||||
|
||||
The title, attendees, date, and other metadata must not be included as this information is already displayed elsewhere in the UI. Your job is the notes themselves only.
|
||||
|
||||
You have the following formatting options:
|
||||
- Headings (markdown-like headings). Use 2 hashtags for headings (“## [heading]”)
|
||||
- Bullet points & indented bullet points. Use 4 spaces to create an indented bullet point.
|
||||
- Headings (markdown-like headings). Use 2 hashtags for headings ("## [heading]")
|
||||
- Bullet points & indented bullet points. Use 4 spaces to create an indented bullet point. Group related points using indented bullet points.
|
||||
|
||||
Note that you cannot use asterisks to bold text or use any other types of formatting.
|
||||
|
||||
@@ -32,26 +27,19 @@ Keep each bullet point concise (5-10 words), and avoid having too many with unim
|
||||
|
||||
Here are explanations of how to handle the different information you will be given:
|
||||
|
||||
<transcript_explanation>
|
||||
Transcripts use specific labels to identify speakers:
|
||||
- "Me": The user using this meeting notes application. Technically speaking, these utterances were picked up by the user's microphone.
|
||||
- "Them": The other person or other people in the meeting. Technically speaking, these utterances were picked up through system audio.
|
||||
<template_explanation>
|
||||
A template typically starts with the template title and context that tells you what type of meeting the user had.
|
||||
|
||||
If there is duplicated transcripts between "Me" and "Them", it means that it was actually "Them" who said it (most likely the user is playing the audio out loud on speakers).
|
||||
Then, it will provide a skeleton of the desired sections for the enhanced notes, with descriptions for each section.
|
||||
|
||||
Keep in mind that transcripts are not always precise, so use context cues and intelligence to parse utterances.
|
||||
Intelligently create the final enhanced notes according to the template context and contents.
|
||||
|
||||
The transcript of the meeting is provided in <transcript> tags below.
|
||||
</transcript_explanation>
|
||||
The template is provided in <template_content> tags below.
|
||||
</template_explanation>
|
||||
|
||||
<user_blurb_explanation>
|
||||
Any additional context the user provided in their global settings will be attached.
|
||||
- This information is always included regardless of the meeting, so it may or may not be relevant.
|
||||
- Use your best judgement to determine if the information is relevant, as it may not be.
|
||||
- It could include personal details, preferences, spellings of companies/people/products, etc.
|
||||
|
||||
This information is provided in <user_blurb> tags below.
|
||||
</user_blurb_explanation>
|
||||
<template_content>
|
||||
{{template_content}}
|
||||
</template_content>
|
||||
|
||||
<user_notes_explanation>
|
||||
The user may manually add notes to the meeting notes. If so, these notes are very important.
|
||||
@@ -87,14 +75,35 @@ In general, the user's notes are very important. These are things that the user
|
||||
The user's notes are provided in <user_notes> tags below.
|
||||
</user_notes_explanation>
|
||||
|
||||
<transcript>
|
||||
{{transcript}}
|
||||
</transcript>
|
||||
<user_notes>
|
||||
{{user_notes}}
|
||||
</user_notes>
|
||||
|
||||
<user_blurb_explanation>
|
||||
Any additional context the user provided in their global settings will be attached.
|
||||
- This information is always included regardless of the meeting, so it may or may not be relevant.
|
||||
- Use your best judgement to determine if the information is relevant, as it may not be.
|
||||
- It could include personal details, preferences, spellings of companies/people/products, etc.
|
||||
|
||||
This information is provided in <user_blurb> tags below.
|
||||
</user_blurb_explanation>
|
||||
|
||||
<user_blurb>
|
||||
{{user_blurb}}
|
||||
</user_blurb>
|
||||
|
||||
<user_notes>
|
||||
{{user_notes}}
|
||||
</user_notes>
|
||||
<transcript_explanation>
|
||||
Transcripts use specific labels to identify speakers:
|
||||
- "Me": The user using this meeting notes application. Technically speaking, these utterances were picked up by the user's microphone.
|
||||
- "Them": The other person or other people in the meeting. Technically speaking, these utterances were picked up through system audio.
|
||||
|
||||
If there is duplicated transcripts between "Me" and "Them", it means that it was actually "Them" who said it (most likely the user is playing the audio out loud on speakers).
|
||||
|
||||
Keep in mind that transcripts are not always precise, so use context cues and intelligence to parse utterances.
|
||||
|
||||
The transcript of the meeting is provided in <transcript> tags below.
|
||||
</transcript_explanation>
|
||||
|
||||
<transcript>
|
||||
{{transcript}}
|
||||
</transcript>
|
||||
@@ -15,11 +15,14 @@ class NotesGenerator {
|
||||
/// - meeting: The meeting object containing all necessary data
|
||||
/// - userBlurb: Information about the user for context
|
||||
/// - systemPrompt: The system prompt template with placeholders
|
||||
/// - templateId: Optional template ID to use for generating notes
|
||||
/// - Returns: AsyncStream of partial generated notes
|
||||
func generateNotesStream(meeting: Meeting,
|
||||
userBlurb: String,
|
||||
systemPrompt: String) -> AsyncStream<String> {
|
||||
return AsyncStream { continuation in
|
||||
systemPrompt: String,
|
||||
templateId: UUID? = nil) -> AsyncStream<String> {
|
||||
|
||||
return AsyncStream<String>(String.self) { continuation in
|
||||
Task {
|
||||
do {
|
||||
guard let apiKey = KeychainHelper.shared.get(forKey: "openAIKey"), !apiKey.isEmpty else {
|
||||
@@ -34,13 +37,30 @@ class NotesGenerator {
|
||||
dateFormatter.dateStyle = .full
|
||||
dateFormatter.timeStyle = .short
|
||||
|
||||
// Load template content
|
||||
var templateContent = ""
|
||||
if let templateId = templateId {
|
||||
let templates = LocalStorageManager.shared.loadTemplates()
|
||||
if let template = templates.first(where: { $0.id == templateId }) {
|
||||
templateContent = template.formattedContent
|
||||
}
|
||||
}
|
||||
|
||||
// If no template content, use default
|
||||
if templateContent.isEmpty {
|
||||
// No template content found, finish the stream
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare template variables
|
||||
let templateVariables: [String: String] = [
|
||||
"meeting_title": meeting.title.isEmpty ? "Untitled Meeting" : meeting.title,
|
||||
"meeting_date": dateFormatter.string(from: meeting.date),
|
||||
"transcript": meeting.formattedTranscript,
|
||||
"user_blurb": userBlurb,
|
||||
"user_notes": meeting.userNotes
|
||||
"user_notes": meeting.userNotes,
|
||||
"template_content": templateContent
|
||||
]
|
||||
|
||||
// Process the system prompt template
|
||||
|
||||
@@ -29,6 +29,8 @@ class MeetingViewModel: ObservableObject {
|
||||
@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>()
|
||||
@@ -62,6 +64,20 @@ class MeetingViewModel: ObservableObject {
|
||||
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
|
||||
@@ -144,6 +160,17 @@ class MeetingViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -151,27 +178,24 @@ class MeetingViewModel: ObservableObject {
|
||||
// Clear existing notes for streaming
|
||||
meeting.generatedNotes = ""
|
||||
|
||||
do {
|
||||
// Load settings for generation
|
||||
let userBlurb = KeychainHelper.shared.get(forKey: "userBlurb") ?? ""
|
||||
let systemPrompt = KeychainHelper.shared.get(forKey: "systemPrompt") ?? Settings.defaultSystemPrompt()
|
||||
|
||||
// Use streaming generation
|
||||
let stream = NotesGenerator.shared.generateNotesStream(
|
||||
meeting: meeting,
|
||||
userBlurb: userBlurb,
|
||||
systemPrompt: systemPrompt
|
||||
)
|
||||
|
||||
for await chunk in stream {
|
||||
meeting.generatedNotes += chunk
|
||||
}
|
||||
|
||||
saveMeeting()
|
||||
} catch {
|
||||
errorMessage = "Failed to generate notes: \(error.localizedDescription)"
|
||||
// Load settings for generation
|
||||
let userBlurb = KeychainHelper.shared.get(forKey: "userBlurb") ?? ""
|
||||
let systemPrompt = KeychainHelper.shared.get(forKey: "systemPrompt") ?? Settings.defaultSystemPrompt()
|
||||
|
||||
// Use streaming generation
|
||||
let stream = NotesGenerator.shared.generateNotesStream(
|
||||
meeting: meeting,
|
||||
userBlurb: userBlurb,
|
||||
systemPrompt: systemPrompt,
|
||||
templateId: selectedTemplateId
|
||||
)
|
||||
|
||||
for await chunk in stream {
|
||||
meeting.generatedNotes += chunk
|
||||
}
|
||||
|
||||
saveMeeting()
|
||||
|
||||
isGeneratingNotes = false
|
||||
}
|
||||
|
||||
|
||||
@@ -5,33 +5,82 @@ class SettingsViewModel: ObservableObject {
|
||||
@Published var settings = Settings()
|
||||
@Published var saveMessage = ""
|
||||
@Published var showingSaveMessage = false
|
||||
@Published var templates: [NoteTemplate] = []
|
||||
|
||||
init() {
|
||||
loadSettings()
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
func loadSettings() {
|
||||
settings.openAIKey = KeychainHelper.shared.get(forKey: "openAIKey") ?? ""
|
||||
settings.userBlurb = KeychainHelper.shared.get(forKey: "userBlurb") ?? ""
|
||||
settings.systemPrompt = KeychainHelper.shared.get(forKey: "systemPrompt") ?? Settings.defaultSystemPrompt()
|
||||
|
||||
// Load selected template ID
|
||||
if let templateIdString = KeychainHelper.shared.get(forKey: "selectedTemplateId"),
|
||||
let templateId = UUID(uuidString: templateIdString) {
|
||||
settings.selectedTemplateId = templateId
|
||||
}
|
||||
}
|
||||
|
||||
func saveSettings() {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveSettings(showMessage: Bool = true) {
|
||||
// Validate that systemPrompt contains all required template placeholders
|
||||
let requiredKeys = ["meeting_title", "meeting_date", "transcript", "user_blurb", "user_notes", "template_content"]
|
||||
let missing = requiredKeys.filter { !settings.systemPrompt.contains("{{\($0)}}") }
|
||||
if !missing.isEmpty {
|
||||
if showMessage {
|
||||
saveMessage = "Cannot save settings: missing placeholders \(missing.map { "{{\($0)}}" }.joined(separator: ", ")) in system prompt"
|
||||
showingSaveMessage = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let openAISaved = KeychainHelper.shared.save(settings.openAIKey, forKey: "openAIKey")
|
||||
let blurbSaved = KeychainHelper.shared.save(settings.userBlurb, forKey: "userBlurb")
|
||||
let promptSaved = KeychainHelper.shared.save(settings.systemPrompt, forKey: "systemPrompt")
|
||||
|
||||
if openAISaved && blurbSaved && promptSaved {
|
||||
saveMessage = "Settings saved successfully!"
|
||||
} else {
|
||||
saveMessage = "Error saving settings"
|
||||
|
||||
// Save selected template ID
|
||||
var templateIdSaved = true
|
||||
if let templateId = settings.selectedTemplateId {
|
||||
templateIdSaved = KeychainHelper.shared.save(templateId.uuidString, forKey: "selectedTemplateId")
|
||||
}
|
||||
|
||||
showingSaveMessage = true
|
||||
|
||||
// Hide the message after 3 seconds
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
self.showingSaveMessage = false
|
||||
|
||||
if showMessage {
|
||||
if openAISaved && blurbSaved && promptSaved && templateIdSaved {
|
||||
saveMessage = "Settings saved successfully!"
|
||||
} else {
|
||||
saveMessage = "Error saving settings"
|
||||
}
|
||||
|
||||
showingSaveMessage = true
|
||||
|
||||
// Hide the message after 3 seconds
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
self.showingSaveMessage = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
class TemplatesViewModel: ObservableObject {
|
||||
@Published var templates: [NoteTemplate] = []
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
init() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
func loadTemplates() {
|
||||
isLoading = true
|
||||
templates = LocalStorageManager.shared.loadTemplates()
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func saveTemplate(_ template: NoteTemplate) {
|
||||
if LocalStorageManager.shared.saveTemplate(template) {
|
||||
loadTemplates()
|
||||
} else {
|
||||
errorMessage = "Failed to save template"
|
||||
}
|
||||
}
|
||||
|
||||
func deleteTemplate(_ template: NoteTemplate) {
|
||||
if LocalStorageManager.shared.deleteTemplate(template) {
|
||||
loadTemplates()
|
||||
} else {
|
||||
errorMessage = "Cannot delete default templates"
|
||||
}
|
||||
}
|
||||
|
||||
func createNewTemplate() -> NoteTemplate {
|
||||
return NoteTemplate(
|
||||
title: "New Template",
|
||||
context: "",
|
||||
sections: [
|
||||
TemplateSection(title: "Section 1", description: "Description of section 1")
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,15 @@ struct MeetingDetailView: View {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
} else {
|
||||
// Template selector
|
||||
Picker("", selection: $viewModel.selectedTemplateId) {
|
||||
ForEach(viewModel.templates) { template in
|
||||
Text(template.title).tag(template.id as UUID?)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 200)
|
||||
|
||||
Button(action: {
|
||||
Task {
|
||||
await viewModel.generateNotes()
|
||||
|
||||
@@ -3,7 +3,6 @@ import SwiftUI
|
||||
struct MeetingListView: View {
|
||||
@StateObject private var viewModel = MeetingListViewModel()
|
||||
@State private var navigationPath = NavigationPath()
|
||||
@State private var showingSettings = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $navigationPath) {
|
||||
@@ -57,7 +56,7 @@ struct MeetingListView: View {
|
||||
.frame(width: 220)
|
||||
// Settings button (middle)
|
||||
Button {
|
||||
showingSettings = true
|
||||
navigationPath.append("settings")
|
||||
} label: {
|
||||
Image(systemName: "gearshape")
|
||||
}
|
||||
@@ -70,12 +69,16 @@ struct MeetingListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSettings) {
|
||||
SettingsView(viewModel: SettingsViewModel())
|
||||
}
|
||||
.navigationDestination(for: Meeting.self) { meeting in
|
||||
MeetingDetailView(meeting: meeting)
|
||||
}
|
||||
.navigationDestination(for: String.self) { path in
|
||||
if path == "settings" {
|
||||
SettingsView(viewModel: SettingsViewModel(), navigationPath: $navigationPath)
|
||||
} else if path == "templates" {
|
||||
TemplateListView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if viewModel.isLoading {
|
||||
|
||||
@@ -24,7 +24,7 @@ struct RenderedNotesView: View {
|
||||
let content = String(trimmed.dropFirst(level + 1)).trimmingCharacters(in: .whitespaces)
|
||||
Text(content)
|
||||
.font(.system(size: CGFloat(18 - level * 2), weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundColor(.primary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else if let (indentLevel, bullet, content) = listItemInfo(for: line) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
|
||||
@@ -2,100 +2,155 @@ import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@ObservedObject var viewModel: SettingsViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showingTemplateManager = false
|
||||
@Binding var navigationPath: NavigationPath
|
||||
|
||||
init(viewModel: SettingsViewModel, navigationPath: Binding<NavigationPath> = .constant(NavigationPath())) {
|
||||
self.viewModel = viewModel
|
||||
self._navigationPath = navigationPath
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
// API Configuration Section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("OpenAI API Key")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
// API Configuration Section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("OpenAI API Key")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Stored locally and encrypted.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
SecureField("OpenAI API Key", text: $viewModel.settings.openAIKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
Text("Stored locally and encrypted.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
// User Information Section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("User Information")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Meetingsnotes works best when it knows a bit about you. You should give your name, role, company, and any other relevant information.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
SecureField("OpenAI API Key", text: $viewModel.settings.openAIKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
// Note Templates Section: only the Manage Templates button
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Note Templates")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Create and manage note templates")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Button {
|
||||
navigationPath.append("templates")
|
||||
} label: {
|
||||
Text("Manage Templates")
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.secondary.opacity(0.2))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// User Information Section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("User Information")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Meetingnotes works best when it knows a bit about you. You should give your name, role, company, and any other relevant information.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextEditor(text: $viewModel.settings.userBlurb)
|
||||
.frame(minHeight: 100)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
|
||||
}
|
||||
TextEditor(text: $viewModel.settings.userBlurb)
|
||||
.frame(minHeight: 100)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
|
||||
// System Prompt Section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
}
|
||||
|
||||
// System Prompt Section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("System Prompt")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
TextEditor(text: $viewModel.settings.systemPrompt)
|
||||
.frame(minHeight: 200)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.resetToDefaults()
|
||||
} label: {
|
||||
Text("Reset to Default")
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.secondary.opacity(0.2))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// GitHub Link Section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("About")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Link(destination: URL(string: "https://github.com/owengretzinger/meetingnotes")!) {
|
||||
HStack {
|
||||
Image(systemName: "chevron.left.forwardslash.chevron.right")
|
||||
.foregroundColor(.blue)
|
||||
Text("View on GitHub")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
TextEditor(text: $viewModel.settings.systemPrompt)
|
||||
.frame(minHeight: 200)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
|
||||
}
|
||||
.padding(24)
|
||||
|
||||
// GitHub Link Section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("About")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
// Link to GitHub repository
|
||||
Link("https://github.com/owengretzinger/meetingnotes",
|
||||
destination: URL(string: "https://github.com/owengretzinger/meetingnotes")!)
|
||||
.foregroundColor(.blue)
|
||||
|
||||
// Link to landing page
|
||||
Link("https://meetingnotes.owengretzinger.com",
|
||||
destination: URL(string: "https://meetingnotes.owengretzinger.com")!)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
|
||||
// Save button
|
||||
Button {
|
||||
viewModel.saveSettings()
|
||||
} label: {
|
||||
Text("Save Settings")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top)
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
viewModel.saveSettings()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 600)
|
||||
.padding(24)
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.frame(minWidth: 600, minHeight: 600)
|
||||
.onAppear {
|
||||
viewModel.loadSettings()
|
||||
viewModel.loadTemplates()
|
||||
}
|
||||
.onDisappear {
|
||||
DispatchQueue.main.async {
|
||||
viewModel.saveSettings(showMessage: false)
|
||||
}
|
||||
}
|
||||
.alert("Settings Saved", isPresented: $viewModel.showingSaveMessage) {
|
||||
Button("OK") { }
|
||||
} message: {
|
||||
Text(viewModel.saveMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SettingsView(viewModel: SettingsViewModel())
|
||||
NavigationStack {
|
||||
SettingsView(viewModel: SettingsViewModel())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TemplateEditView: View {
|
||||
@State private var template: NoteTemplate
|
||||
let onSave: (NoteTemplate) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
init(template: NoteTemplate, onSave: @escaping (NoteTemplate) -> Void) {
|
||||
self._template = State(initialValue: template)
|
||||
self.onSave = onSave
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical, showsIndicators: true) {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
// Template Name
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Template Name")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
TextField("Template Name", text: $template.title)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
// Context
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Meeting Context")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Provide context about the type of meeting this template is for")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextEditor(text: $template.context)
|
||||
.frame(minHeight: 100)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
// Sections
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Sections")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
withAnimation {
|
||||
template.sections.append(
|
||||
TemplateSection(
|
||||
title: "New Section",
|
||||
description: "Description of this section"
|
||||
)
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "plus")
|
||||
Text("Add Section")
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Text("Define the sections that will appear in the generated notes")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
ForEach(template.sections.indices, id: \.self) { index in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextField("Section Title", text: $template.sections[index].title)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
TextEditor(text: $template.sections[index].description)
|
||||
.frame(minHeight: 60)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
Button {
|
||||
withAnimation {
|
||||
let _ = template.sections.remove(at: index)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
// Adaptive background for light & dark mode
|
||||
.background(
|
||||
Color.secondary.opacity(0.1)
|
||||
)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
// Save button
|
||||
Button {
|
||||
onSave(template)
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Save Template")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top)
|
||||
.disabled(template.title.isEmpty || template.sections.isEmpty)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.navigationTitle("Edit Template")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
TemplateEditView(
|
||||
template: NoteTemplate(
|
||||
title: "Sample Template",
|
||||
context: "This is a sample context",
|
||||
sections: [
|
||||
TemplateSection(title: "Section 1", description: "Description 1"),
|
||||
TemplateSection(title: "Section 2", description: "Description 2")
|
||||
]
|
||||
)
|
||||
) { _ in }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TemplateListView: View {
|
||||
@StateObject private var viewModel = TemplatesViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(viewModel.templates) { template in
|
||||
HStack {
|
||||
NavigationLink(destination: TemplateEditView(template: template) { updatedTemplate in
|
||||
viewModel.saveTemplate(updatedTemplate)
|
||||
}) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(template.title)
|
||||
.font(.headline)
|
||||
if template.isDefault {
|
||||
Text("Default")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.secondary.opacity(0.2))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
if !template.context.isEmpty {
|
||||
Text(template.context)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Text("\(template.sections.count) sections")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !template.isDefault {
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteTemplate(template)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
if !template.isDefault {
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteTemplate(template)
|
||||
} label: {
|
||||
Label("Delete Template", systemImage: "trash")
|
||||
}
|
||||
} else {
|
||||
Text("Cannot delete default template")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
viewModel.deleteTemplate(viewModel.templates[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Note Templates")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
NavigationLink(destination: TemplateEditView(template: viewModel.createNewTemplate()) { updatedTemplate in
|
||||
viewModel.saveTemplate(updatedTemplate)
|
||||
}) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if viewModel.isLoading {
|
||||
ProgressView("Loading templates...")
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
|
||||
Button("OK") {
|
||||
viewModel.errorMessage = nil
|
||||
}
|
||||
} message: {
|
||||
Text(viewModel.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TemplateListView()
|
||||
}
|
||||
Reference in New Issue
Block a user