Files
notes/Notes/Views/Projects/AssignProjectSheet.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

110 lines
4.2 KiB
Swift

import IndieSync
import SwiftData
import SwiftUI
/// Sheet for filing one note under a project: pick an existing one, clear the
/// current assignment, or create a new one on the spot. Opened from a leading
/// swipe in `NotesListView`/`ProjectNotesView`.
struct AssignProjectSheet: View {
let note: NoteEntity
@Environment(SyncEngine.self) private var syncEngine
@Environment(\.dismiss) private var dismiss
@Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity]
@State private var newName = ""
@AppStorage(ProjectsNaming.storageKey) private var namingRaw = ProjectsNaming.projects.rawValue
private var naming: ProjectsNaming { ProjectsNaming(rawValue: namingRaw) ?? .projects }
@AppStorage(ProjectsPrefs.declaredKey) private var declaredRaw = ""
var body: some View {
NavigationStack {
List {
if note.project != nil {
Section {
Button(role: .destructive) {
assign(nil)
} label: {
Label("Remove from \(naming.singular)", systemImage: "folder.badge.minus")
}
}
}
if !existingProjects.isEmpty {
Section(naming.plural) {
ForEach(existingProjects, id: \.project) { entry in
Button {
assign(entry.project)
} label: {
HStack {
Label(entry.project, systemImage: "folder")
Spacer()
Text("\(entry.count)")
.foregroundStyle(.secondary)
.monospacedDigit()
if note.project == entry.project {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
}
}
.buttonStyle(.plain)
}
}
}
Section("New \(naming.singular)") {
TextField("Name", text: $newName)
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.words)
#endif
Button("File Under New \(naming.singular)") {
assign(trimmedNewName)
}
.disabled(trimmedNewName.isEmpty)
}
}
.navigationTitle("File Under…")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
}
private var trimmedNewName: String {
newName.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// Distinct project names across live notes, unioned with names declared
/// but not yet used (count 0) so a freshly "+"-created project is
/// immediately offered here, count-sorted like `ProjectsView` so the
/// most-used projects surface first.
private var existingProjects: [(project: String, count: Int)] {
var counts: [String: Int] = [:]
for note in notes where note.isLive {
guard let project = note.project else { continue }
counts[project, default: 0] += 1
}
for name in ProjectsPrefs.decodeDeclared(declaredRaw) where counts[name] == nil {
counts[name] = 0
}
return counts
.map { (project: $0.key, count: $0.value) }
.sorted { $0.count == $1.count ? $0.project < $1.project : $0.count > $1.count }
}
private func assign(_ project: String?) {
guard let id = ULID(string: note.id) else { return }
Task {
try? await syncEngine.assignProject(id: id, project: project)
dismiss()
}
}
}