NotesAppRootView.swift 15 KB

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