Add Projects: third tab, swipe-to-file, per-project note capture
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
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
|
||||
/// What the user calls the third tab's grouping. "Projects" by default, but
|
||||
/// people organize differently — the label is a Settings choice, applied to
|
||||
/// the tab title, list headers, and swipe/assign UI. More options will come;
|
||||
/// adding one means adding a case here.
|
||||
enum ProjectsNaming: String, CaseIterable, Identifiable {
|
||||
case projects
|
||||
case categories
|
||||
|
||||
static let storageKey = "projectsNaming"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var plural: String {
|
||||
switch self {
|
||||
case .projects: "Projects"
|
||||
case .categories: "Categories"
|
||||
}
|
||||
}
|
||||
|
||||
var singular: String {
|
||||
switch self {
|
||||
case .projects: "Project"
|
||||
case .categories: "Category"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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 }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(projectCounts, id: \.project) { entry in
|
||||
NavigationLink(value: entry.project) {
|
||||
HStack {
|
||||
Label(entry.project, systemImage: "folder")
|
||||
Spacer()
|
||||
Text("\(entry.count)")
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(naming.plural)
|
||||
.navigationDestination(for: String.self) { project in
|
||||
ProjectNotesView(project: project)
|
||||
}
|
||||
.overlay {
|
||||
if projectCounts.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()).")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var projectCounts: [(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 }
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user