Selaa lähdekoodia

Add SwiftUI notes UI with local JSON persistence

Embed a split-view Notes editor into the existing macOS app shell and persist notes via a local JSON file in Application Support.

Made-with: Cursor
huzaifahayat12 4 kuukautta sitten
vanhempi
sitoutus
85fbeefb61

+ 8 - 1
notes_app/AppDelegate.swift

@@ -14,7 +14,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
 
 
     func applicationDidFinishLaunching(_ aNotification: Notification) {
-        // Insert code here to initialize your application
+        // The storyboard window is configured with visibleAtLaunch="NO", so we
+        // force it to become key/front after launch.
+        DispatchQueue.main.async {
+            NSApp.activate(ignoringOtherApps: true)
+            if let window = NSApplication.shared.windows.first {
+                window.makeKeyAndOrderFront(nil)
+            }
+        }
     }
 
     func applicationWillTerminate(_ aNotification: Notification) {

+ 23 - 0
notes_app/Models/Note.swift

@@ -0,0 +1,23 @@
+import Foundation
+
+struct Note: Identifiable, Codable, Hashable {
+    var id: UUID
+    var title: String
+    var body: String
+    var updatedAt: Date
+
+    init(
+        id: UUID = UUID(),
+        title: String,
+        body: String,
+        updatedAt: Date = Date()
+    ) {
+        self.id = id
+        self.title = title
+        self.body = body
+        self.updatedAt = updatedAt
+    }
+
+    static let placeholderTitle = "Untitled"
+}
+

+ 41 - 0
notes_app/Persistence/FileNotesRepository.swift

@@ -0,0 +1,41 @@
+import Foundation
+
+struct FileNotesRepository {
+    private let notesURL: URL
+
+    init(notesURL: URL? = nil) {
+        if let notesURL {
+            self.notesURL = notesURL
+        } else {
+            let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+            let appDir = appSupport.appendingPathComponent("notes_app", isDirectory: true)
+            self.notesURL = appDir.appendingPathComponent("notes.json")
+        }
+    }
+
+    func loadNotes() throws -> [Note] {
+        let fm = FileManager.default
+        guard fm.fileExists(atPath: notesURL.path) else { return [] }
+
+        let data = try Data(contentsOf: notesURL)
+        guard !data.isEmpty else { return [] }
+
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .iso8601
+        return try decoder.decode([Note].self, from: data)
+    }
+
+    func saveNotes(_ notes: [Note]) throws {
+        let fm = FileManager.default
+        let parentDir = notesURL.deletingLastPathComponent()
+        try fm.createDirectory(at: parentDir, withIntermediateDirectories: true)
+
+        let encoder = JSONEncoder()
+        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+        encoder.dateEncodingStrategy = .iso8601
+
+        let data = try encoder.encode(notes)
+        try data.write(to: notesURL, options: [.atomic])
+    }
+}
+

+ 110 - 0
notes_app/Stores/NotesStore.swift

@@ -0,0 +1,110 @@
+import Foundation
+import Combine
+
+@MainActor
+final class NotesStore: ObservableObject {
+    @Published private(set) var notes: [Note] = []
+    @Published var selectedNoteID: UUID?
+    @Published var searchQuery: String = ""
+
+    private let repository: FileNotesRepository
+    private var saveTask: Task<Void, Never>?
+
+    init(repository: FileNotesRepository = FileNotesRepository()) {
+        self.repository = repository
+
+        do {
+            let loadedNotes = try repository.loadNotes()
+            self.notes = loadedNotes.sorted(by: { $0.updatedAt > $1.updatedAt })
+        } catch {
+            self.notes = []
+        }
+
+        self.selectedNoteID = notes.first?.id
+    }
+
+    var filteredNotes: [Note] {
+        let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !query.isEmpty else { return notes }
+
+        let q = query.lowercased()
+        return notes.filter { note in
+            note.title.lowercased().contains(q) || note.body.lowercased().contains(q)
+        }
+    }
+
+    var selectedNote: Note? {
+        guard let id = selectedNoteID else { return nil }
+        return notes.first(where: { $0.id == id })
+    }
+
+    func noteByID(_ id: UUID) -> Note? {
+        notes.first(where: { $0.id == id })
+    }
+
+    func createNote() {
+        let now = Date()
+        let note = Note(
+            title: Note.placeholderTitle,
+            body: "",
+            updatedAt: now
+        )
+
+        notes.insert(note, at: 0)
+        selectedNoteID = note.id
+        sortNotesInPlace()
+        scheduleSave()
+    }
+
+    func updateNoteTitle(noteID: UUID, title: String) {
+        guard let index = notes.firstIndex(where: { $0.id == noteID }) else { return }
+        notes[index].title = title
+        notes[index].updatedAt = Date()
+        sortNotesInPlace()
+        scheduleSave()
+    }
+
+    func updateNoteBody(noteID: UUID, body: String) {
+        guard let index = notes.firstIndex(where: { $0.id == noteID }) else { return }
+        notes[index].body = body
+        notes[index].updatedAt = Date()
+        sortNotesInPlace()
+        scheduleSave()
+    }
+
+    func delete(noteID: UUID) {
+        guard let index = notes.firstIndex(where: { $0.id == noteID }) else { return }
+        notes.remove(at: index)
+
+        if selectedNoteID == noteID {
+            selectedNoteID = notes.first?.id
+        }
+
+        scheduleSave()
+    }
+
+    private func sortNotesInPlace() {
+        notes.sort(by: { $0.updatedAt > $1.updatedAt })
+    }
+
+    private func scheduleSave() {
+        saveTask?.cancel()
+
+        let snapshot = notes
+        let repository = self.repository
+
+        saveTask = Task { @MainActor in
+            try? await Task.sleep(nanoseconds: 500_000_000)
+            do {
+                try repository.saveNotes(snapshot)
+            } catch {
+                // Keep UI responsive; persistence failures will be visible via stderr in Debug.
+                // You can wire this into an error UI later if desired.
+                #if DEBUG
+                print("Failed saving notes: \(error)")
+                #endif
+            }
+        }
+    }
+}
+

+ 13 - 1
notes_app/ViewController.swift

@@ -6,13 +6,25 @@
 //
 
 import Cocoa
+import SwiftUI
 
 class ViewController: NSViewController {
+    private let store = NotesStore()
 
     override func viewDidLoad() {
         super.viewDidLoad()
 
-        // Do any additional setup after loading the view.
+        // Embed SwiftUI inside the existing storyboard window.
+        let rootView = NotesRootView(store: store)
+        let hostingView = NSHostingView(rootView: rootView)
+        hostingView.translatesAutoresizingMaskIntoConstraints = false
+        view.addSubview(hostingView)
+        NSLayoutConstraint.activate([
+            hostingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+            hostingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+            hostingView.topAnchor.constraint(equalTo: view.topAnchor),
+            hostingView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
+        ])
     }
 
     override var representedObject: Any? {

+ 72 - 0
notes_app/Views/NoteEditorView.swift

@@ -0,0 +1,72 @@
+import SwiftUI
+
+struct NoteEditorView: View {
+    @ObservedObject var store: NotesStore
+    @State private var showDeleteAlert = false
+
+    var body: some View {
+        Group {
+            if let selectedID = store.selectedNoteID, let _ = store.noteByID(selectedID) {
+                editorBody(for: selectedID)
+            } else {
+                VStack {
+                    Text("No note selected")
+                        .foregroundStyle(.secondary)
+                }
+                .frame(maxWidth: .infinity, maxHeight: .infinity)
+            }
+        }
+    }
+
+    @ViewBuilder
+    private func editorBody(for selectedID: UUID) -> some View {
+        let rawTitle = store.noteByID(selectedID)?.title ?? ""
+        let navTitle = rawTitle.isEmpty ? Note.placeholderTitle : rawTitle
+
+        VStack(alignment: .leading, spacing: 12) {
+            TextField(
+                "Title",
+                text: Binding(
+                    get: { store.noteByID(selectedID)?.title ?? "" },
+                    set: { newValue in
+                        store.updateNoteTitle(noteID: selectedID, title: newValue)
+                    }
+                )
+            )
+            .textFieldStyle(.roundedBorder)
+            .font(.title3)
+
+            TextEditor(
+                text: Binding(
+                    get: { store.noteByID(selectedID)?.body ?? "" },
+                    set: { newValue in
+                        store.updateNoteBody(noteID: selectedID, body: newValue)
+                    }
+                )
+            )
+            .font(.body)
+        }
+        .padding(12)
+        .navigationTitle(navTitle)
+        .toolbar {
+            ToolbarItem(placement: .primaryAction) {
+                Button(role: .destructive) {
+                    showDeleteAlert = true
+                } label: {
+                    Label("Delete", systemImage: "trash")
+                }
+            }
+        }
+        .alert("Delete Note?", isPresented: $showDeleteAlert) {
+            Button("Delete", role: .destructive) {
+                store.delete(noteID: selectedID)
+            }
+            Button("Cancel", role: .cancel) {
+                showDeleteAlert = false
+            }
+        } message: {
+            Text("This action cannot be undone.")
+        }
+    }
+}
+

+ 26 - 0
notes_app/Views/NotesListView.swift

@@ -0,0 +1,26 @@
+import SwiftUI
+
+struct NotesListView: View {
+    @ObservedObject var store: NotesStore
+
+    var body: some View {
+        List(selection: $store.selectedNoteID) {
+            ForEach(store.filteredNotes) { note in
+                Text(note.title.isEmpty ? Note.placeholderTitle : note.title)
+                    .lineLimit(1)
+                    .tag(note.id)
+            }
+        }
+        .searchable(text: $store.searchQuery, prompt: "Search notes")
+        .toolbar {
+            ToolbarItem(placement: .primaryAction) {
+                Button {
+                    store.createNote()
+                } label: {
+                    Label("New Note", systemImage: "plus")
+                }
+            }
+        }
+    }
+}
+

+ 14 - 0
notes_app/Views/NotesRootView.swift

@@ -0,0 +1,14 @@
+import SwiftUI
+
+struct NotesRootView: View {
+    @ObservedObject var store: NotesStore
+
+    var body: some View {
+        NavigationSplitView {
+            NotesListView(store: store)
+        } detail: {
+            NoteEditorView(store: store)
+        }
+    }
+}
+