NotesAppRootView.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. import SwiftUI
  2. import AppKit
  3. private struct NoteCollection: Identifiable, Hashable {
  4. enum Kind: Hashable {
  5. case notes
  6. case archive
  7. case recentlyDeleted
  8. }
  9. let id: UUID
  10. let name: String
  11. let kind: Kind
  12. }
  13. struct Note: Identifiable, Equatable, Hashable, Codable {
  14. let id: UUID
  15. var title: String
  16. var body: String
  17. var updatedAt: Date
  18. var isArchived: Bool
  19. var isDeleted: Bool
  20. var deletedAt: Date?
  21. }
  22. struct NotesAppRootView: View {
  23. fileprivate enum EditorFocusField: Hashable {
  24. case title
  25. case body
  26. }
  27. @State private var searchText: String = ""
  28. private static let defaultCollectionID: UUID = UUID(uuidString: "2E6A2D4E-0B3D-4E8A-A5B7-8D9E0C9E2A01")!
  29. private static let archiveCollectionID: UUID = UUID(uuidString: "2E6A2D4E-0B3D-4E8A-A5B7-8D9E0C9E2A03")!
  30. private static let recentlyDeletedCollectionID: UUID = UUID(uuidString: "2E6A2D4E-0B3D-4E8A-A5B7-8D9E0C9E2A04")!
  31. @State private var selectedCollectionID: UUID? = NotesAppRootView.defaultCollectionID
  32. @State private var selectedNoteID: UUID? = nil
  33. @State private var editorFocus: EditorFocusField? = nil
  34. @State private var showPermanentDeleteConfirmation = false
  35. @StateObject private var store = NotesStore()
  36. private var collections: [NoteCollection] = [
  37. NoteCollection(id: NotesAppRootView.defaultCollectionID, name: "Notes", kind: .notes),
  38. NoteCollection(id: NotesAppRootView.archiveCollectionID, name: "Archive", kind: .archive),
  39. NoteCollection(id: NotesAppRootView.recentlyDeletedCollectionID, name: "Recently Deleted", kind: .recentlyDeleted)
  40. ]
  41. private var selectedCollection: NoteCollection? {
  42. collections.first { $0.id == selectedCollectionID }
  43. }
  44. private var filteredNotes: [Note] {
  45. guard let selectedCollection else { return [] }
  46. let scoped: [Note]
  47. switch selectedCollection.kind {
  48. case .notes:
  49. scoped = store.notes.filter { !$0.isDeleted && !$0.isArchived }
  50. case .archive:
  51. scoped = store.notes.filter { !$0.isDeleted && $0.isArchived }
  52. case .recentlyDeleted:
  53. scoped = store.notes.filter { $0.isDeleted }
  54. }
  55. let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  56. if trimmed.isEmpty { return scoped }
  57. return scoped.filter {
  58. $0.title.lowercased().contains(trimmed) || $0.body.lowercased().contains(trimmed)
  59. }
  60. }
  61. private func addNote() {
  62. let now = Date()
  63. let newNote = Note(
  64. id: UUID(),
  65. title: "New Note",
  66. body: "",
  67. updatedAt: now,
  68. isArchived: false,
  69. isDeleted: false,
  70. deletedAt: nil
  71. )
  72. selectedCollectionID = NotesAppRootView.defaultCollectionID
  73. store.notes.insert(newNote, at: 0)
  74. selectedNoteID = newNote.id
  75. editorFocus = .title
  76. }
  77. private var canDeleteSelectedNote: Bool {
  78. guard let selectedNoteID else { return false }
  79. return store.notes.contains { $0.id == selectedNoteID }
  80. }
  81. private var canMoveToRecentlyDeleted: Bool {
  82. guard let selectedCollection else { return false }
  83. guard let selectedNoteID else { return false }
  84. guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false }
  85. if note.isDeleted { return false }
  86. return selectedCollection.kind == .notes || selectedCollection.kind == .archive
  87. }
  88. private func moveSelectedNoteToRecentlyDeleted() {
  89. guard let selectedNoteID else { return }
  90. guard let index = store.notes.firstIndex(where: { $0.id == selectedNoteID }) else { return }
  91. store.notes[index].isDeleted = true
  92. store.notes[index].deletedAt = Date()
  93. // Stay on the current sidebar section (e.g. Notes); clear selection since the note left this list.
  94. self.selectedNoteID = nil
  95. editorFocus = nil
  96. }
  97. private var canPermanentlyDeleteSelectedNote: Bool {
  98. guard let selectedCollection else { return false }
  99. guard let selectedNoteID else { return false }
  100. guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false }
  101. return selectedCollection.kind == .recentlyDeleted && note.isDeleted
  102. }
  103. private func permanentlyDeleteSelectedNote() {
  104. guard let selectedNoteID else { return }
  105. store.notes.removeAll { $0.id == selectedNoteID }
  106. self.selectedNoteID = nil
  107. editorFocus = nil
  108. }
  109. private var canRestoreSelectedNote: Bool {
  110. guard let selectedCollection else { return false }
  111. guard let selectedNoteID else { return false }
  112. guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false }
  113. return selectedCollection.kind == .recentlyDeleted && note.isDeleted
  114. }
  115. private func restoreSelectedNote() {
  116. guard let selectedNoteID else { return }
  117. guard let index = store.notes.firstIndex(where: { $0.id == selectedNoteID }) else { return }
  118. store.notes[index].isDeleted = false
  119. store.notes[index].deletedAt = nil
  120. if store.notes[index].isArchived {
  121. selectedCollectionID = NotesAppRootView.archiveCollectionID
  122. } else {
  123. selectedCollectionID = NotesAppRootView.defaultCollectionID
  124. }
  125. self.selectedNoteID = store.notes[index].id
  126. }
  127. private var canArchiveSelectedNote: Bool {
  128. guard let selectedNoteID else { return false }
  129. guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false }
  130. guard let selectedCollection else { return false }
  131. if note.isDeleted { return false }
  132. return selectedCollection.kind == .notes && !note.isArchived
  133. }
  134. private func archiveSelectedNote() {
  135. guard let selectedNoteID else { return }
  136. guard let index = store.notes.firstIndex(where: { $0.id == selectedNoteID }) else { return }
  137. store.notes[index].isArchived = true
  138. // Stay on Notes; clear selection since archived notes no longer appear in this list.
  139. self.selectedNoteID = nil
  140. editorFocus = nil
  141. }
  142. private var canUnarchiveSelectedNote: Bool {
  143. guard let selectedNoteID else { return false }
  144. guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false }
  145. guard let selectedCollection else { return false }
  146. if note.isDeleted { return false }
  147. return selectedCollection.kind == .archive && note.isArchived
  148. }
  149. private func unarchiveSelectedNote() {
  150. guard let selectedNoteID else { return }
  151. guard let index = store.notes.firstIndex(where: { $0.id == selectedNoteID }) else { return }
  152. store.notes[index].isArchived = false
  153. selectedCollectionID = NotesAppRootView.defaultCollectionID
  154. self.selectedNoteID = store.notes[index].id
  155. editorFocus = nil
  156. }
  157. var body: some View {
  158. NavigationSplitView {
  159. SidebarView(
  160. collections: collections,
  161. notes: store.notes,
  162. selectedCollectionID: $selectedCollectionID
  163. )
  164. } content: {
  165. VStack(spacing: 0) {
  166. HStack(spacing: 10) {
  167. SearchBar(text: $searchText)
  168. Button(action: addNote) {
  169. Image(systemName: "square.and.pencil")
  170. .font(.system(size: 14, weight: .semibold))
  171. }
  172. .buttonStyle(.borderless)
  173. .keyboardShortcut("n", modifiers: [.command])
  174. .help("New Note")
  175. Button(action: moveSelectedNoteToRecentlyDeleted) {
  176. Image(systemName: "trash")
  177. .font(.system(size: 14, weight: .semibold))
  178. }
  179. .buttonStyle(.borderless)
  180. .keyboardShortcut(.delete, modifiers: [.command])
  181. .help("Delete Note")
  182. .disabled(!canMoveToRecentlyDeleted)
  183. Button(action: restoreSelectedNote) {
  184. Image(systemName: "arrow.uturn.left")
  185. .font(.system(size: 14, weight: .semibold))
  186. }
  187. .buttonStyle(.borderless)
  188. .keyboardShortcut("z", modifiers: [.command, .shift])
  189. .help("Restore Note")
  190. .disabled(!canRestoreSelectedNote)
  191. Button(action: { showPermanentDeleteConfirmation = true }) {
  192. Image(systemName: "trash.slash")
  193. .font(.system(size: 14, weight: .semibold))
  194. }
  195. .buttonStyle(.borderless)
  196. .keyboardShortcut(.delete, modifiers: [.command, .option])
  197. .help("Delete Permanently")
  198. .disabled(!canPermanentlyDeleteSelectedNote)
  199. Button(action: archiveSelectedNote) {
  200. Image(systemName: "archivebox")
  201. .font(.system(size: 14, weight: .semibold))
  202. }
  203. .buttonStyle(.borderless)
  204. .keyboardShortcut("a", modifiers: [.command, .shift])
  205. .help("Archive Note")
  206. .disabled(!canArchiveSelectedNote)
  207. Button(action: unarchiveSelectedNote) {
  208. Image(systemName: "tray.and.arrow.up")
  209. .font(.system(size: 14, weight: .semibold))
  210. }
  211. .buttonStyle(.borderless)
  212. .keyboardShortcut("u", modifiers: [.command, .shift])
  213. .help("Unarchive Note")
  214. .disabled(!canUnarchiveSelectedNote)
  215. .padding(.trailing, 12)
  216. }
  217. Group {
  218. if filteredNotes.isEmpty {
  219. EmptyListView(text: "No Notes")
  220. } else {
  221. NotesListView(
  222. notes: filteredNotes,
  223. selectedNoteID: $selectedNoteID
  224. )
  225. }
  226. }
  227. .frame(maxWidth: .infinity, maxHeight: .infinity)
  228. }
  229. .background(Color(nsColor: .textBackgroundColor))
  230. } detail: {
  231. NoteEditorView(
  232. notes: $store.notes,
  233. selectedNoteID: selectedNoteID,
  234. focusField: $editorFocus
  235. )
  236. .background(Color(nsColor: .textBackgroundColor))
  237. }
  238. .preferredColorScheme(.dark)
  239. .onAppear {
  240. store.purgeExpiredRecentlyDeletedIfNeeded()
  241. }
  242. .alert("Delete permanently?", isPresented: $showPermanentDeleteConfirmation) {
  243. Button("Cancel", role: .cancel) {}
  244. Button("Delete Permanently", role: .destructive) {
  245. permanentlyDeleteSelectedNote()
  246. }
  247. } message: {
  248. Text("This note will be permanently removed from this device. Notes in Recently Deleted are also removed automatically after 30 days. You cannot undo this action.")
  249. }
  250. }
  251. }
  252. private struct SidebarView: View {
  253. let collections: [NoteCollection]
  254. let notes: [Note]
  255. @Binding var selectedCollectionID: UUID?
  256. private func notesCount(for collection: NoteCollection) -> Int {
  257. switch collection.kind {
  258. case .notes:
  259. return notes.filter { !$0.isDeleted && !$0.isArchived }.count
  260. case .archive:
  261. return notes.filter { !$0.isDeleted && $0.isArchived }.count
  262. case .recentlyDeleted:
  263. return notes.filter { $0.isDeleted }.count
  264. }
  265. }
  266. var body: some View {
  267. List(selection: $selectedCollectionID) {
  268. Section(header: Text("Keep Notebook")) {
  269. ForEach(collections) { collection in
  270. HStack(spacing: 8) {
  271. Text(collection.name)
  272. .foregroundStyle(.primary)
  273. Spacer(minLength: 8)
  274. Text("\(notesCount(for: collection))")
  275. .foregroundStyle(.secondary)
  276. .font(.system(size: 12, weight: .semibold))
  277. }
  278. .tag(collection.id)
  279. }
  280. }
  281. }
  282. .listStyle(.sidebar)
  283. .frame(minWidth: 210)
  284. }
  285. }
  286. private struct SearchBar: View {
  287. @Binding var text: String
  288. var body: some View {
  289. HStack(spacing: 8) {
  290. Image(systemName: "magnifyingglass")
  291. .foregroundStyle(.secondary)
  292. TextField("Search", text: $text)
  293. .textFieldStyle(.plain)
  294. .foregroundStyle(.primary)
  295. }
  296. .padding(10)
  297. .background(Color(nsColor: .windowBackgroundColor))
  298. .overlay(
  299. RoundedRectangle(cornerRadius: 10)
  300. .stroke(Color(nsColor: .separatorColor).opacity(0.35), lineWidth: 1)
  301. )
  302. .padding(12)
  303. }
  304. }
  305. private struct EmptyListView: View {
  306. let text: String
  307. var body: some View {
  308. VStack {
  309. Spacer()
  310. Text(text)
  311. .foregroundStyle(.secondary)
  312. .font(.system(size: 16, weight: .semibold))
  313. Spacer()
  314. }
  315. .frame(maxWidth: .infinity, maxHeight: .infinity)
  316. }
  317. }
  318. private struct NotesListView: View {
  319. let notes: [Note]
  320. @Binding var selectedNoteID: UUID?
  321. var body: some View {
  322. List(selection: $selectedNoteID) {
  323. ForEach(notes) { note in
  324. HStack(spacing: 8) {
  325. Text(note.title)
  326. .lineLimit(1)
  327. Spacer(minLength: 8)
  328. }
  329. .tag(note.id)
  330. }
  331. }
  332. .listStyle(.inset)
  333. }
  334. }
  335. private struct NoteEditorView: View {
  336. @Binding var notes: [Note]
  337. let selectedNoteID: UUID?
  338. @Binding var focusField: NotesAppRootView.EditorFocusField?
  339. @FocusState private var focusedField: NotesAppRootView.EditorFocusField?
  340. var body: some View {
  341. VStack(spacing: 10) {
  342. if let selectedNoteID,
  343. let index = notes.firstIndex(where: { $0.id == selectedNoteID }) {
  344. TextField("Title", text: $notes[index].title)
  345. .textFieldStyle(.plain)
  346. .font(.system(size: 22, weight: .semibold))
  347. .padding(.horizontal, 14)
  348. .focused($focusedField, equals: .title)
  349. Divider()
  350. .padding(.horizontal, 14)
  351. TextEditor(text: $notes[index].body)
  352. .font(.system(size: 14))
  353. .foregroundStyle(.primary)
  354. .padding(14)
  355. .background(Color(nsColor: .textBackgroundColor))
  356. .focused($focusedField, equals: .body)
  357. } else {
  358. // Keep the editor empty to match the screenshot's "nothing selected" state.
  359. Spacer()
  360. }
  361. }
  362. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
  363. .onChange(of: focusField) { _, newValue in
  364. guard let newValue else { return }
  365. focusedField = newValue
  366. focusField = nil
  367. }
  368. }
  369. }