diff --git a/Notes/Views/Projects/AssignProjectSheet.swift b/Notes/Views/Projects/AssignProjectSheet.swift index b13ce2d..7c0bd88 100644 --- a/Notes/Views/Projects/AssignProjectSheet.swift +++ b/Notes/Views/Projects/AssignProjectSheet.swift @@ -17,6 +17,8 @@ struct AssignProjectSheet: View { @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 { @@ -79,14 +81,19 @@ struct AssignProjectSheet: View { newName.trimmingCharacters(in: .whitespacesAndNewlines) } - /// Distinct project names across live notes, count-sorted like - /// `ProjectsView` so the most-used projects surface first. + /// 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 } diff --git a/Notes/Views/Projects/ProjectsPrefs.swift b/Notes/Views/Projects/ProjectsPrefs.swift new file mode 100644 index 0000000..194f270 --- /dev/null +++ b/Notes/Views/Projects/ProjectsPrefs.swift @@ -0,0 +1,38 @@ +import SwiftUI + +/// Device-local Projects-tab preferences: the sort order and the names the +/// user has declared via "+" before any note was filed into them. Declared +/// names are deliberately NOT synced — a project becomes part of the synced +/// data the moment a note carries it; until then it is scaffolding that only +/// this device knows about. +enum ProjectsPrefs { + static let declaredKey = "declaredProjects" + static let sortKey = "projectsSort" + + /// Newline-joined storage — names are single-line (the creation alert's + /// text field can't produce newlines) so this stays trivially inspectable. + static func decodeDeclared(_ raw: String) -> [String] { + raw.split(separator: "\n").map(String.init) + } + + static func encodeDeclared(_ names: [String]) -> String { + names.joined(separator: "\n") + } +} + +/// Sort orders for the project list. +enum ProjectsSort: String, CaseIterable, Identifiable { + case activity + case name + case count + + var id: String { rawValue } + + var label: String { + switch self { + case .activity: "Recent Activity" + case .name: "Name" + case .count: "Note Count" + } + } +} diff --git a/Notes/Views/Projects/ProjectsView.swift b/Notes/Views/Projects/ProjectsView.swift index 2ed2fd1..aa97dc8 100644 --- a/Notes/Views/Projects/ProjectsView.swift +++ b/Notes/Views/Projects/ProjectsView.swift @@ -12,46 +12,126 @@ struct ProjectsView: View { @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(projectCounts, id: \.project) { entry in - NavigationLink(value: entry.project) { + ForEach(projects, id: \.name) { entry in + NavigationLink(value: entry.name) { HStack { - Label(entry.project, systemImage: "folder") + 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 projectCounts.isEmpty { + 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()).") + 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() + } + } } } - private var projectCounts: [(project: String, count: Int)] { + /// 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) } - return counts - .map { (project: $0.key, count: $0.value) } - .sorted { $0.count == $1.count ? $0.project < $1.project : $0.count > $1.count } + 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 }) } } diff --git a/README.md b/README.md index 058d3cb..38bc7ab 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ the top of the list. - **Automatic context capture** — location, place name (reverse-geocoded), time zone, and device are stamped onto every note, best-effort and privacy-friendly (all data stays in your iCloud) - **"Right here, right now" recall** — a ranked shelf of notes whose capture context matches your current place and time, above the regular newest-first list - **Tags** — manual organization alongside the automatic context; browse notes by tag -- **Projects** — file notes under a project (or "category" — the term is your choice in Settings) with a swipe, browse them in their own tab, and add notes directly into a project +- **Projects** — file notes under a project (or "category" — the term is your choice in Settings) with a swipe, browse them in their own tab, create empty ones ahead of time with the tab's "+", sort by activity/name/count, and add notes directly into a project - **Search** — full-text over note text, voice transcripts (weighted by transcription confidence), tags, and captured place names - **iCloud sync** — notes live as JSON files in your iCloud Drive (visible in Files.app) and sync across iPhone and Mac; the local database is just a rebuildable cache - **iOS + macOS + watchOS** — same app and data across iPhone and Mac, with an Apple Watch companion for voice capture