|
|
@@ -0,0 +1,209 @@
|
|
|
+import SwiftUI
|
|
|
+import AppKit
|
|
|
+
|
|
|
+private enum Provider: String, CaseIterable, Identifiable {
|
|
|
+ case iCloud = "iCloud"
|
|
|
+ case google = "Google"
|
|
|
+
|
|
|
+ var id: String { rawValue }
|
|
|
+}
|
|
|
+
|
|
|
+private struct NoteCollection: Identifiable, Hashable {
|
|
|
+ let id: UUID
|
|
|
+ let provider: Provider
|
|
|
+ let name: String
|
|
|
+}
|
|
|
+
|
|
|
+private struct Note: Identifiable, Equatable, Hashable {
|
|
|
+ let id: UUID
|
|
|
+ var title: String
|
|
|
+ var body: String
|
|
|
+ var updatedAt: Date
|
|
|
+}
|
|
|
+
|
|
|
+struct NotesAppRootView: View {
|
|
|
+ @State private var searchText: String = ""
|
|
|
+ private static let defaultCollectionID: UUID = UUID(uuidString: "2E6A2D4E-0B3D-4E8A-A5B7-8D9E0C9E2A01")!
|
|
|
+ @State private var selectedCollectionID: UUID? = NotesAppRootView.defaultCollectionID
|
|
|
+ @State private var selectedNoteID: UUID? = nil
|
|
|
+
|
|
|
+ // Keep this local for now: the goal is to design/match the UI.
|
|
|
+ @State private var notes: [Note] = []
|
|
|
+
|
|
|
+ private var collections: [NoteCollection] = [
|
|
|
+ NoteCollection(id: NotesAppRootView.defaultCollectionID, provider: .iCloud, name: "Notes")
|
|
|
+ ]
|
|
|
+
|
|
|
+ private var selectedCollection: NoteCollection? {
|
|
|
+ collections.first { $0.id == selectedCollectionID }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var filteredNotes: [Note] {
|
|
|
+ // Since this is a UI-first pass, we treat notes as belonging to the iCloud "Notes" collection only.
|
|
|
+ guard let selectedCollection else { return [] }
|
|
|
+
|
|
|
+ // Currently notes are empty by default to match your screenshot.
|
|
|
+ let scoped = (selectedCollection.provider == .iCloud) ? notes : []
|
|
|
+
|
|
|
+ let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
+ if trimmed.isEmpty { return scoped }
|
|
|
+
|
|
|
+ return scoped.filter {
|
|
|
+ $0.title.lowercased().contains(trimmed) || $0.body.lowercased().contains(trimmed)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ NavigationSplitView {
|
|
|
+ SidebarView(
|
|
|
+ collections: collections,
|
|
|
+ notes: notes,
|
|
|
+ selectedCollectionID: $selectedCollectionID
|
|
|
+ )
|
|
|
+ } content: {
|
|
|
+ VStack(spacing: 0) {
|
|
|
+ SearchBar(text: $searchText)
|
|
|
+
|
|
|
+ Group {
|
|
|
+ if filteredNotes.isEmpty {
|
|
|
+ EmptyListView(text: "No Notes")
|
|
|
+ } else {
|
|
|
+ NotesListView(
|
|
|
+ notes: filteredNotes,
|
|
|
+ selectedNoteID: $selectedNoteID
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
+ }
|
|
|
+ .background(Color(nsColor: .textBackgroundColor))
|
|
|
+ } detail: {
|
|
|
+ NoteEditorView(
|
|
|
+ notes: $notes,
|
|
|
+ selectedNoteID: selectedNoteID
|
|
|
+ )
|
|
|
+ .background(Color(nsColor: .textBackgroundColor))
|
|
|
+ }
|
|
|
+ .preferredColorScheme(.dark)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct SidebarView: View {
|
|
|
+ let collections: [NoteCollection]
|
|
|
+ let notes: [Note]
|
|
|
+
|
|
|
+ @Binding var selectedCollectionID: UUID?
|
|
|
+
|
|
|
+ private func notesCount(for collection: NoteCollection) -> Int {
|
|
|
+ if collection.provider != .iCloud { return 0 }
|
|
|
+ return notes.count
|
|
|
+ }
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ List(selection: $selectedCollectionID) {
|
|
|
+ ForEach(Provider.allCases) { provider in
|
|
|
+ Section(header: Text(provider == .iCloud ? "iCloud" : "Google")) {
|
|
|
+ ForEach(collections.filter { $0.provider == provider }) { collection in
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ Text(collection.name)
|
|
|
+ .foregroundStyle(.primary)
|
|
|
+ Spacer(minLength: 8)
|
|
|
+ Text("\(notesCount(for: collection))")
|
|
|
+ .foregroundStyle(.secondary)
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
+ }
|
|
|
+ .tag(collection.id)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .listStyle(.sidebar)
|
|
|
+ .frame(minWidth: 210)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct SearchBar: View {
|
|
|
+ @Binding var text: String
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ Image(systemName: "magnifyingglass")
|
|
|
+ .foregroundStyle(.secondary)
|
|
|
+ TextField("Search", text: $text)
|
|
|
+ .textFieldStyle(.plain)
|
|
|
+ .foregroundStyle(.primary)
|
|
|
+ }
|
|
|
+ .padding(10)
|
|
|
+ .background(Color(nsColor: .windowBackgroundColor))
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: 10)
|
|
|
+ .stroke(Color(nsColor: .separatorColor).opacity(0.35), lineWidth: 1)
|
|
|
+ )
|
|
|
+ .padding(12)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct EmptyListView: View {
|
|
|
+ let text: String
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ VStack {
|
|
|
+ Spacer()
|
|
|
+ Text(text)
|
|
|
+ .foregroundStyle(.secondary)
|
|
|
+ .font(.system(size: 16, weight: .semibold))
|
|
|
+ Spacer()
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct NotesListView: View {
|
|
|
+ let notes: [Note]
|
|
|
+ @Binding var selectedNoteID: UUID?
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ List(selection: $selectedNoteID) {
|
|
|
+ ForEach(notes) { note in
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ Text(note.title)
|
|
|
+ .lineLimit(1)
|
|
|
+ Spacer(minLength: 8)
|
|
|
+ }
|
|
|
+ .tag(note.id)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .listStyle(.inset)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct NoteEditorView: View {
|
|
|
+ @Binding var notes: [Note]
|
|
|
+ let selectedNoteID: UUID?
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ VStack(spacing: 10) {
|
|
|
+ if let selectedNoteID,
|
|
|
+ let index = notes.firstIndex(where: { $0.id == selectedNoteID }) {
|
|
|
+ TextField("Title", text: $notes[index].title)
|
|
|
+ .textFieldStyle(.plain)
|
|
|
+ .font(.system(size: 22, weight: .semibold))
|
|
|
+ .padding(.horizontal, 14)
|
|
|
+
|
|
|
+ Divider()
|
|
|
+ .padding(.horizontal, 14)
|
|
|
+
|
|
|
+ TextEditor(text: $notes[index].body)
|
|
|
+ .font(.system(size: 14))
|
|
|
+ .foregroundStyle(.primary)
|
|
|
+ .padding(14)
|
|
|
+ .background(Color(nsColor: .textBackgroundColor))
|
|
|
+ } else {
|
|
|
+ // Keep the editor empty to match the screenshot's "nothing selected" state.
|
|
|
+ Spacer()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|