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
This commit is contained in:
@@ -17,6 +17,8 @@ struct AssignProjectSheet: View {
|
|||||||
@AppStorage(ProjectsNaming.storageKey) private var namingRaw = ProjectsNaming.projects.rawValue
|
@AppStorage(ProjectsNaming.storageKey) private var namingRaw = ProjectsNaming.projects.rawValue
|
||||||
private var naming: ProjectsNaming { ProjectsNaming(rawValue: namingRaw) ?? .projects }
|
private var naming: ProjectsNaming { ProjectsNaming(rawValue: namingRaw) ?? .projects }
|
||||||
|
|
||||||
|
@AppStorage(ProjectsPrefs.declaredKey) private var declaredRaw = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
@@ -79,14 +81,19 @@ struct AssignProjectSheet: View {
|
|||||||
newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Distinct project names across live notes, count-sorted like
|
/// Distinct project names across live notes, unioned with names declared
|
||||||
/// `ProjectsView` so the most-used projects surface first.
|
/// 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)] {
|
private var existingProjects: [(project: String, count: Int)] {
|
||||||
var counts: [String: Int] = [:]
|
var counts: [String: Int] = [:]
|
||||||
for note in notes where note.isLive {
|
for note in notes where note.isLive {
|
||||||
guard let project = note.project else { continue }
|
guard let project = note.project else { continue }
|
||||||
counts[project, default: 0] += 1
|
counts[project, default: 0] += 1
|
||||||
}
|
}
|
||||||
|
for name in ProjectsPrefs.decodeDeclared(declaredRaw) where counts[name] == nil {
|
||||||
|
counts[name] = 0
|
||||||
|
}
|
||||||
return counts
|
return counts
|
||||||
.map { (project: $0.key, count: $0.value) }
|
.map { (project: $0.key, count: $0.value) }
|
||||||
.sorted { $0.count == $1.count ? $0.project < $1.project : $0.count > $1.count }
|
.sorted { $0.count == $1.count ? $0.project < $1.project : $0.count > $1.count }
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,46 +12,126 @@ struct ProjectsView: View {
|
|||||||
@AppStorage(ProjectsNaming.storageKey) private var namingRaw = ProjectsNaming.projects.rawValue
|
@AppStorage(ProjectsNaming.storageKey) private var namingRaw = ProjectsNaming.projects.rawValue
|
||||||
private var naming: ProjectsNaming { ProjectsNaming(rawValue: namingRaw) ?? .projects }
|
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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
ForEach(projectCounts, id: \.project) { entry in
|
ForEach(projects, id: \.name) { entry in
|
||||||
NavigationLink(value: entry.project) {
|
NavigationLink(value: entry.name) {
|
||||||
HStack {
|
HStack {
|
||||||
Label(entry.project, systemImage: "folder")
|
Label(entry.name, systemImage: "folder")
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(entry.count)")
|
Text("\(entry.count)")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.monospacedDigit()
|
.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)
|
.navigationTitle(naming.plural)
|
||||||
.navigationDestination(for: String.self) { project in
|
.navigationDestination(for: String.self) { project in
|
||||||
ProjectNotesView(project: project)
|
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 {
|
.overlay {
|
||||||
if projectCounts.isEmpty {
|
if projects.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"No \(naming.plural.lowercased()) yet",
|
"No \(naming.plural.lowercased()) yet",
|
||||||
systemImage: "folder",
|
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 counts: [String: Int] = [:]
|
||||||
|
var lastActivity: [String: Date] = [:]
|
||||||
for note in notes where note.isLive {
|
for note in notes where note.isLive {
|
||||||
guard let project = note.project else { continue }
|
guard let project = note.project else { continue }
|
||||||
counts[project, default: 0] += 1
|
counts[project, default: 0] += 1
|
||||||
|
lastActivity[project] = max(lastActivity[project] ?? note.createdAt, note.createdAt)
|
||||||
}
|
}
|
||||||
return counts
|
for name in ProjectsPrefs.decodeDeclared(declaredRaw) where counts[name] == nil {
|
||||||
.map { (project: $0.key, count: $0.value) }
|
counts[name] = 0
|
||||||
.sorted { $0.count == $1.count ? $0.project < $1.project : $0.count > $1.count }
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
- **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
|
- **"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
|
- **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
|
- **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
|
- **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
|
- **iOS + macOS + watchOS** — same app and data across iPhone and Mac, with an Apple Watch companion for voice capture
|
||||||
|
|||||||
Reference in New Issue
Block a user