Files
notes/Notes/Views/Projects/ProjectsView.swift
T
rzen c5e8aea522 Projects tab: create via +, sort by activity/name/count
Declared-but-empty projects persist locally (UserDefaults) until a
note carries the name, at which point the project syncs via notes as
usual. Empty declared projects can be swipe-deleted.

Claude-Session: https://claude.ai/code/session_014esDWi42URLEC6Cj17hGQ3
2026-07-16 23:22:25 -04:00

211 lines
7.9 KiB
Swift

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)
}
}
}