Notes gain an optional project (schema v4, additive). Projects derive from note values like tags — no separate entity. Tab naming (Projects/Categories) is a Settings choice applied across the UI. Claude-Session: https://claude.ai/code/session_014esDWi42URLEC6Cj17hGQ3
103 lines
3.9 KiB
Swift
103 lines
3.9 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 }
|
|
|
|
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, 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
|
|
}
|
|
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()
|
|
}
|
|
}
|
|
}
|