import IndieSync import SwiftData import SwiftUI /// Browse notes by project — the manual-organization counterpart to the /// automatic context ranking, and a sibling to `TagsView`. Unlike tags a note /// carries at most one project, so browsing here is filing rather than /// cross-cutting search. struct ProjectsView: View { @Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity] @AppStorage(ProjectsNaming.storageKey) private var namingRaw = ProjectsNaming.projects.rawValue private var naming: ProjectsNaming { ProjectsNaming(rawValue: namingRaw) ?? .projects } @AppStorage(ProjectsPrefs.declaredKey) private var declaredRaw = "" @AppStorage(ProjectsPrefs.sortKey) private var sortRaw = ProjectsSort.activity.rawValue private var sort: ProjectsSort { ProjectsSort(rawValue: sortRaw) ?? .activity } @State private var creating = false @State private var newName = "" var body: some View { NavigationStack { List { ForEach(projects, id: \.name) { entry in NavigationLink(value: entry.name) { HStack { Label(entry.name, systemImage: "folder") Spacer() Text("\(entry.count)") .foregroundStyle(.secondary) .monospacedDigit() } } .swipeActions(edge: .trailing) { // Only a declared-but-empty project can be deleted here — // one a note carries exists because of that note, not // because of a local declaration. if entry.count == 0 { Button(role: .destructive) { removeDeclared(entry.name) } label: { Label("Delete", systemImage: "trash") } } } } } .navigationTitle(naming.plural) .navigationDestination(for: String.self) { project in ProjectNotesView(project: project) } .toolbar { ToolbarItem(placement: .primaryAction) { Button { creating = true } label: { Label("New \(naming.singular)", systemImage: "plus") } } ToolbarItem(placement: .primaryAction) { Menu { Picker("Sort By", selection: $sortRaw) { ForEach(ProjectsSort.allCases) { option in Text(option.label).tag(option.rawValue) } } } label: { Label("Sort", systemImage: "arrow.up.arrow.down") } } } .overlay { if projects.isEmpty { ContentUnavailableView( "No \(naming.plural.lowercased()) yet", systemImage: "folder", description: Text("Swipe a note in the list to file it under a \(naming.singular.lowercased()), or tap + to create one.") ) } } .alert("New \(naming.singular)", isPresented: $creating) { TextField("Name", text: $newName) Button("Cancel", role: .cancel) { newName = "" } Button("Create") { create() } } } } /// Live projects (from notes that carry them) unioned with names /// declared-but-not-yet-used, sorted per `sort`. private var projects: [(name: String, count: Int, lastActivity: Date?)] { var counts: [String: Int] = [:] var lastActivity: [String: Date] = [:] for note in notes where note.isLive { guard let project = note.project else { continue } counts[project, default: 0] += 1 lastActivity[project] = max(lastActivity[project] ?? note.createdAt, note.createdAt) } for name in ProjectsPrefs.decodeDeclared(declaredRaw) where counts[name] == nil { counts[name] = 0 } let entries = counts.map { (name: $0.key, count: $0.value, lastActivity: lastActivity[$0.key]) } switch sort { case .activity: return entries.sorted { lhs, rhs in switch (lhs.lastActivity, rhs.lastActivity) { case let (l?, r?): l > r case (nil, nil): lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending case (nil, _): false case (_, nil): true } } case .name: return entries.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } case .count: return entries.sorted { $0.count == $1.count ? $0.name < $1.name : $0.count > $1.count } } } private func create() { let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) newName = "" guard !trimmed.isEmpty, !projects.contains(where: { $0.name == trimmed }) else { return } declaredRaw = ProjectsPrefs.encodeDeclared(ProjectsPrefs.decodeDeclared(declaredRaw) + [trimmed]) } private func removeDeclared(_ name: String) { declaredRaw = ProjectsPrefs.encodeDeclared(ProjectsPrefs.decodeDeclared(declaredRaw).filter { $0 != name }) } } struct ProjectNotesView: View { let project: String @Environment(SyncEngine.self) private var syncEngine @Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity] @State private var composing = false @State private var editingNote: NoteEntity? @State private var assigningNote: NoteEntity? @AppStorage(ProjectsNaming.storageKey) private var namingRaw = ProjectsNaming.projects.rawValue private var naming: ProjectsNaming { ProjectsNaming(rawValue: namingRaw) ?? .projects } var body: some View { List { ForEach(notes.filter { $0.isLive && $0.project == project }) { note in row(note) } } .navigationTitle(project) .toolbar { ToolbarItem(placement: .primaryAction) { Button { composing = true } label: { Label("New Note", systemImage: "square.and.pencil") } } } .sheet(isPresented: $composing) { NoteEditorView(mode: .create, defaultProject: project) } .sheet(item: $editingNote) { note in NoteEditorView(mode: .edit(note)) } .sheet(item: $assigningNote) { note in AssignProjectSheet(note: note) } } private func row(_ note: NoteEntity) -> some View { Button { editingNote = note } label: { NoteRowView(note: note) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) .swipeActions(edge: .leading) { Button { assigningNote = note } label: { Label(naming.singular, systemImage: "folder") } .tint(.indigo) } .swipeActions(edge: .trailing) { Button(role: .destructive) { delete(note) } label: { Label("Delete", systemImage: "trash") } } } private func delete(_ note: NoteEntity) { guard let id = ULID(string: note.id) else { return } Task { try? await syncEngine.deleteNote(id: id) } } }