|
@@ -9,9 +9,15 @@ private enum Provider: String, CaseIterable, Identifiable {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private struct NoteCollection: Identifiable, Hashable {
|
|
private struct NoteCollection: Identifiable, Hashable {
|
|
|
|
|
+ enum Kind: Hashable {
|
|
|
|
|
+ case notes
|
|
|
|
|
+ case archive
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
let id: UUID
|
|
let id: UUID
|
|
|
let provider: Provider
|
|
let provider: Provider
|
|
|
let name: String
|
|
let name: String
|
|
|
|
|
+ let kind: Kind
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private struct Note: Identifiable, Equatable, Hashable {
|
|
private struct Note: Identifiable, Equatable, Hashable {
|
|
@@ -19,19 +25,28 @@ private struct Note: Identifiable, Equatable, Hashable {
|
|
|
var title: String
|
|
var title: String
|
|
|
var body: String
|
|
var body: String
|
|
|
var updatedAt: Date
|
|
var updatedAt: Date
|
|
|
|
|
+ var isArchived: Bool
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
struct NotesAppRootView: View {
|
|
struct NotesAppRootView: View {
|
|
|
|
|
+ fileprivate enum EditorFocusField: Hashable {
|
|
|
|
|
+ case title
|
|
|
|
|
+ case body
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@State private var searchText: String = ""
|
|
@State private var searchText: String = ""
|
|
|
private static let defaultCollectionID: UUID = UUID(uuidString: "2E6A2D4E-0B3D-4E8A-A5B7-8D9E0C9E2A01")!
|
|
private static let defaultCollectionID: UUID = UUID(uuidString: "2E6A2D4E-0B3D-4E8A-A5B7-8D9E0C9E2A01")!
|
|
|
|
|
+ private static let archiveCollectionID: UUID = UUID(uuidString: "2E6A2D4E-0B3D-4E8A-A5B7-8D9E0C9E2A03")!
|
|
|
@State private var selectedCollectionID: UUID? = NotesAppRootView.defaultCollectionID
|
|
@State private var selectedCollectionID: UUID? = NotesAppRootView.defaultCollectionID
|
|
|
@State private var selectedNoteID: UUID? = nil
|
|
@State private var selectedNoteID: UUID? = nil
|
|
|
|
|
+ @State private var editorFocus: EditorFocusField? = nil
|
|
|
|
|
|
|
|
// Keep this local for now: the goal is to design/match the UI.
|
|
// Keep this local for now: the goal is to design/match the UI.
|
|
|
@State private var notes: [Note] = []
|
|
@State private var notes: [Note] = []
|
|
|
|
|
|
|
|
private var collections: [NoteCollection] = [
|
|
private var collections: [NoteCollection] = [
|
|
|
- NoteCollection(id: NotesAppRootView.defaultCollectionID, provider: .iCloud, name: "Notes")
|
|
|
|
|
|
|
+ NoteCollection(id: NotesAppRootView.defaultCollectionID, provider: .iCloud, name: "Notes", kind: .notes),
|
|
|
|
|
+ NoteCollection(id: NotesAppRootView.archiveCollectionID, provider: .iCloud, name: "Archive", kind: .archive)
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
private var selectedCollection: NoteCollection? {
|
|
private var selectedCollection: NoteCollection? {
|
|
@@ -42,8 +57,17 @@ struct NotesAppRootView: View {
|
|
|
// Since this is a UI-first pass, we treat notes as belonging to the iCloud "Notes" collection only.
|
|
// Since this is a UI-first pass, we treat notes as belonging to the iCloud "Notes" collection only.
|
|
|
guard let selectedCollection else { return [] }
|
|
guard let selectedCollection else { return [] }
|
|
|
|
|
|
|
|
- // Currently notes are empty by default to match your screenshot.
|
|
|
|
|
- let scoped = (selectedCollection.provider == .iCloud) ? notes : []
|
|
|
|
|
|
|
+ let scoped: [Note]
|
|
|
|
|
+ if selectedCollection.provider != .iCloud {
|
|
|
|
|
+ scoped = []
|
|
|
|
|
+ } else {
|
|
|
|
|
+ switch selectedCollection.kind {
|
|
|
|
|
+ case .notes:
|
|
|
|
|
+ scoped = notes.filter { !$0.isArchived }
|
|
|
|
|
+ case .archive:
|
|
|
|
|
+ scoped = notes.filter { $0.isArchived }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
if trimmed.isEmpty { return scoped }
|
|
if trimmed.isEmpty { return scoped }
|
|
@@ -53,6 +77,50 @@ struct NotesAppRootView: View {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func addNote() {
|
|
|
|
|
+ let now = Date()
|
|
|
|
|
+ let newNote = Note(
|
|
|
|
|
+ id: UUID(),
|
|
|
|
|
+ title: "New Note",
|
|
|
|
|
+ body: "",
|
|
|
|
|
+ updatedAt: now,
|
|
|
|
|
+ isArchived: false
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ selectedCollectionID = NotesAppRootView.defaultCollectionID
|
|
|
|
|
+ notes.insert(newNote, at: 0)
|
|
|
|
|
+ selectedNoteID = newNote.id
|
|
|
|
|
+ editorFocus = .title
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var canDeleteSelectedNote: Bool {
|
|
|
|
|
+ guard let selectedNoteID else { return false }
|
|
|
|
|
+ return notes.contains { $0.id == selectedNoteID }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func deleteSelectedNote() {
|
|
|
|
|
+ guard let selectedNoteID else { return }
|
|
|
|
|
+ notes.removeAll { $0.id == selectedNoteID }
|
|
|
|
|
+ self.selectedNoteID = nil
|
|
|
|
|
+ editorFocus = nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var canArchiveSelectedNote: Bool {
|
|
|
|
|
+ guard let selectedNoteID else { return false }
|
|
|
|
|
+ guard let note = notes.first(where: { $0.id == selectedNoteID }) else { return false }
|
|
|
|
|
+ return !note.isArchived
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func archiveSelectedNote() {
|
|
|
|
|
+ guard let selectedNoteID else { return }
|
|
|
|
|
+ guard let index = notes.firstIndex(where: { $0.id == selectedNoteID }) else { return }
|
|
|
|
|
+
|
|
|
|
|
+ notes[index].isArchived = true
|
|
|
|
|
+ selectedCollectionID = NotesAppRootView.archiveCollectionID
|
|
|
|
|
+ self.selectedNoteID = notes[index].id
|
|
|
|
|
+ editorFocus = nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
var body: some View {
|
|
var body: some View {
|
|
|
NavigationSplitView {
|
|
NavigationSplitView {
|
|
|
SidebarView(
|
|
SidebarView(
|
|
@@ -62,7 +130,36 @@ struct NotesAppRootView: View {
|
|
|
)
|
|
)
|
|
|
} content: {
|
|
} content: {
|
|
|
VStack(spacing: 0) {
|
|
VStack(spacing: 0) {
|
|
|
- SearchBar(text: $searchText)
|
|
|
|
|
|
|
+ HStack(spacing: 10) {
|
|
|
|
|
+ SearchBar(text: $searchText)
|
|
|
|
|
+
|
|
|
|
|
+ Button(action: addNote) {
|
|
|
|
|
+ Image(systemName: "square.and.pencil")
|
|
|
|
|
+ .font(.system(size: 14, weight: .semibold))
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.borderless)
|
|
|
|
|
+ .keyboardShortcut("n", modifiers: [.command])
|
|
|
|
|
+ .help("New Note")
|
|
|
|
|
+
|
|
|
|
|
+ Button(action: deleteSelectedNote) {
|
|
|
|
|
+ Image(systemName: "trash")
|
|
|
|
|
+ .font(.system(size: 14, weight: .semibold))
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.borderless)
|
|
|
|
|
+ .keyboardShortcut(.delete, modifiers: [.command])
|
|
|
|
|
+ .help("Delete Note")
|
|
|
|
|
+ .disabled(!canDeleteSelectedNote)
|
|
|
|
|
+
|
|
|
|
|
+ Button(action: archiveSelectedNote) {
|
|
|
|
|
+ Image(systemName: "archivebox")
|
|
|
|
|
+ .font(.system(size: 14, weight: .semibold))
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.borderless)
|
|
|
|
|
+ .keyboardShortcut("a", modifiers: [.command, .shift])
|
|
|
|
|
+ .help("Archive Note")
|
|
|
|
|
+ .disabled(!canArchiveSelectedNote)
|
|
|
|
|
+ .padding(.trailing, 12)
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
Group {
|
|
Group {
|
|
|
if filteredNotes.isEmpty {
|
|
if filteredNotes.isEmpty {
|
|
@@ -80,7 +177,8 @@ struct NotesAppRootView: View {
|
|
|
} detail: {
|
|
} detail: {
|
|
|
NoteEditorView(
|
|
NoteEditorView(
|
|
|
notes: $notes,
|
|
notes: $notes,
|
|
|
- selectedNoteID: selectedNoteID
|
|
|
|
|
|
|
+ selectedNoteID: selectedNoteID,
|
|
|
|
|
+ focusField: $editorFocus
|
|
|
)
|
|
)
|
|
|
.background(Color(nsColor: .textBackgroundColor))
|
|
.background(Color(nsColor: .textBackgroundColor))
|
|
|
}
|
|
}
|
|
@@ -96,7 +194,12 @@ private struct SidebarView: View {
|
|
|
|
|
|
|
|
private func notesCount(for collection: NoteCollection) -> Int {
|
|
private func notesCount(for collection: NoteCollection) -> Int {
|
|
|
if collection.provider != .iCloud { return 0 }
|
|
if collection.provider != .iCloud { return 0 }
|
|
|
- return notes.count
|
|
|
|
|
|
|
+ switch collection.kind {
|
|
|
|
|
+ case .notes:
|
|
|
|
|
+ return notes.filter { !$0.isArchived }.count
|
|
|
|
|
+ case .archive:
|
|
|
|
|
+ return notes.filter { $0.isArchived }.count
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
var body: some View {
|
|
var body: some View {
|
|
@@ -180,6 +283,9 @@ private struct NotesListView: View {
|
|
|
private struct NoteEditorView: View {
|
|
private struct NoteEditorView: View {
|
|
|
@Binding var notes: [Note]
|
|
@Binding var notes: [Note]
|
|
|
let selectedNoteID: UUID?
|
|
let selectedNoteID: UUID?
|
|
|
|
|
+ @Binding var focusField: NotesAppRootView.EditorFocusField?
|
|
|
|
|
+
|
|
|
|
|
+ @FocusState private var focusedField: NotesAppRootView.EditorFocusField?
|
|
|
|
|
|
|
|
var body: some View {
|
|
var body: some View {
|
|
|
VStack(spacing: 10) {
|
|
VStack(spacing: 10) {
|
|
@@ -189,6 +295,7 @@ private struct NoteEditorView: View {
|
|
|
.textFieldStyle(.plain)
|
|
.textFieldStyle(.plain)
|
|
|
.font(.system(size: 22, weight: .semibold))
|
|
.font(.system(size: 22, weight: .semibold))
|
|
|
.padding(.horizontal, 14)
|
|
.padding(.horizontal, 14)
|
|
|
|
|
+ .focused($focusedField, equals: .title)
|
|
|
|
|
|
|
|
Divider()
|
|
Divider()
|
|
|
.padding(.horizontal, 14)
|
|
.padding(.horizontal, 14)
|
|
@@ -198,12 +305,18 @@ private struct NoteEditorView: View {
|
|
|
.foregroundStyle(.primary)
|
|
.foregroundStyle(.primary)
|
|
|
.padding(14)
|
|
.padding(14)
|
|
|
.background(Color(nsColor: .textBackgroundColor))
|
|
.background(Color(nsColor: .textBackgroundColor))
|
|
|
|
|
+ .focused($focusedField, equals: .body)
|
|
|
} else {
|
|
} else {
|
|
|
// Keep the editor empty to match the screenshot's "nothing selected" state.
|
|
// Keep the editor empty to match the screenshot's "nothing selected" state.
|
|
|
Spacer()
|
|
Spacer()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
|
|
|
+ .onChange(of: focusField) { _, newValue in
|
|
|
|
|
+ guard let newValue else { return }
|
|
|
|
|
+ focusedField = newValue
|
|
|
|
|
+ focusField = nil
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|