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:
2026-07-16 18:24:11 -04:00
parent a8085865bc
commit b2f16e8600
14 changed files with 395 additions and 11 deletions
+11 -4
View File
@@ -45,8 +45,10 @@ struct Note: SyncDocument, VersionedDocument, Equatable {
/// own writing, and every transcription attempt is preserved. v1/v2 files
/// decode unchanged (missing keys nil/empty); `migrateIfNeeded` back-fills
/// v2 voice notes by moving a completed transcript from `text` into
/// `attempts` at read time.
static let currentSchemaVersion = 3
/// `attempts` at read time. v4 added optional `payload.project` purely
/// additive, so v1v3 files decode unchanged (missing key nil) with no
/// read-time transformation needed.
static let currentSchemaVersion = 4
struct Meta: Codable, Sendable, Equatable {
var id: ULID
@@ -149,6 +151,10 @@ struct Note: SyncDocument, VersionedDocument, Equatable {
/// Voice-note sidecar metadata, nil for a plain typed note. Optional so
/// a v1 document (no `audio` key) decodes as a text note unchanged.
var audio: AudioInfo?
/// The single project (or "category" user's choice, see
/// `ProjectsNaming`) this note is filed under. Nil means unfiled.
/// Optional so a pre-v4 document (no `project` key) decodes unchanged.
var project: String? = nil
}
var meta: Meta
@@ -186,12 +192,13 @@ struct Note: SyncDocument, VersionedDocument, Equatable {
source: Source = .typed,
tags: [String] = [],
context: CaptureContext,
audio: AudioInfo? = nil
audio: AudioInfo? = nil,
project: String? = nil
) -> Note {
let now = Date()
return Note(
meta: Meta(id: ULID(), schemaVersion: currentSchemaVersion, createdAt: now, modifiedAt: now),
payload: Payload(text: text, source: source, tags: tags, context: context, audio: audio)
payload: Payload(text: text, source: source, tags: tags, context: context, audio: audio, project: project)
)
}
+2
View File
@@ -10,6 +10,8 @@ final class NoteEntity {
var text: String = ""
var sourceRaw: String = Note.Source.typed.rawValue
var tags: [String] = []
/// The project (or "category") this note is filed under, nil when unfiled.
var project: String?
var createdAt: Date = Date()
var modifiedAt: Date = Date()
+3 -1
View File
@@ -31,6 +31,7 @@ enum NoteMapper {
entity.text = note.payload.text
entity.sourceRaw = note.payload.source.rawValue
entity.tags = note.payload.tags
entity.project = note.payload.project
entity.createdAt = note.meta.createdAt
entity.modifiedAt = note.meta.modifiedAt
entity.jsonRelativePath = relativePath
@@ -54,7 +55,8 @@ enum NoteMapper {
source: entity.source,
tags: entity.tags,
context: entity.context,
audio: entity.audio
audio: entity.audio,
project: entity.project
)
)
}
+22 -4
View File
@@ -136,9 +136,10 @@ final class SyncEngine {
text: String,
source: Note.Source = .typed,
tags: [String] = [],
context: CaptureContext
context: CaptureContext,
project: String? = nil
) async throws -> Note {
let note = Note.create(text: text, source: source, tags: tags, context: context)
let note = Note.create(text: text, source: source, tags: tags, context: context, project: project)
try await save(note)
return note
}
@@ -202,7 +203,8 @@ final class SyncEngine {
text: String = "",
tags: [String] = [],
context: CaptureContext,
transcriberDeviceID: String
transcriberDeviceID: String,
project: String? = nil
) async throws -> Note {
guard let store else {
report("Note not saved — iCloud not connected")
@@ -215,7 +217,7 @@ final class SyncEngine {
transcriptionLocaleID: nil,
transcribedAt: nil
)
let note = Note.create(text: text, source: .transcribed, tags: tags, context: context, audio: audio)
let note = Note.create(text: text, source: .transcribed, tags: tags, context: context, audio: audio, project: project)
do {
let audioData = try Data(contentsOf: audioFileURL)
try await store.writeData(audioData, to: Self.dataPath(Note.audioRelativePath(forID: note.id)))
@@ -322,6 +324,20 @@ final class SyncEngine {
try await updateNote(note)
}
// MARK: - Organization
/// Assign a note to a project (or clear it with nil). Re-reads the file
/// the authority so a concurrent edit isn't clobbered; the cache catches
/// up via the monitor as usual.
func assignProject(id: ULID, project: String?) async throws {
guard let store else { throw SyncError.iCloudUnavailable }
guard let raw = try? await store.read(Note.self, from: Self.dataPath(Note.relativePath(forID: id))),
raw.isReadable else { return }
var note = migrateIfNeeded(raw)
note.payload.project = project
try await updateNote(note)
}
// MARK: - Watch ingestion
/// Local directory where the phone receiver (`PhoneWatchReceiver`, a later
@@ -763,6 +779,8 @@ final class SyncEngine {
/// the transcript merged with user typing indistinguishable by then) in
/// `text`; move that whole text into the first attempt so the v3 invariant
/// "text is user writing only" holds. Confidence was never recorded nil.
/// v3 v4 is additive-only (new optional `payload.project`), so no
/// migration action is needed beyond the version stamp.
/// Internal (not private) so the migration policy is unit-testable.
func migrateIfNeeded(_ note: Note) -> Note {
guard note.meta.schemaVersion < Note.currentSchemaVersion else { return note }
+7
View File
@@ -3,6 +3,9 @@ import SwiftUI
struct ContentView: View {
@Environment(SyncEngine.self) private var syncEngine
@AppStorage(ProjectsNaming.storageKey) private var namingRaw = ProjectsNaming.projects.rawValue
private var naming: ProjectsNaming { ProjectsNaming(rawValue: namingRaw) ?? .projects }
var body: some View {
if syncEngine.iCloudStatus == .unavailable {
ICloudGateView()
@@ -16,6 +19,10 @@ struct ContentView: View {
TagsView()
}
Tab(naming.plural, systemImage: "folder") {
ProjectsView()
}
Tab("Settings", systemImage: "gearshape") {
SettingsView()
}
+10 -2
View File
@@ -37,6 +37,12 @@ struct NoteEditorView: View {
let mode: Mode
/// Project a note created from this editor is pre-filed under (e.g. when
/// composing from inside `ProjectNotesView`). Ignored on the edit path
/// `updateNote` only rewrites text/tags on the loaded note, so an
/// existing note's `payload.project` is left untouched.
var defaultProject: String? = nil
@Environment(SyncEngine.self) private var syncEngine
@Environment(ContextService.self) private var contextService
@Environment(TranscriptionService.self) private var transcriptionService
@@ -326,7 +332,8 @@ struct NoteEditorView: View {
text: trimmed,
tags: parsedTags,
context: contextService.current,
transcriberDeviceID: TranscriptionSettings.installID
transcriberDeviceID: TranscriptionSettings.installID,
project: defaultProject
)
// Fast path: hand the recording's temp file straight to the
// transcriber so it doesn't round-trip through the container.
@@ -335,7 +342,8 @@ struct NoteEditorView: View {
try await syncEngine.createNote(
text: trimmed,
tags: parsedTags,
context: contextService.current
context: contextService.current,
project: defaultProject
)
}
case .edit(let entity):
+15
View File
@@ -12,6 +12,10 @@ struct NotesListView: View {
@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 {
NavigationStack {
@@ -33,6 +37,9 @@ struct NotesListView: View {
.sheet(item: $editingNote) { note in
NoteEditorView(mode: .edit(note))
}
.sheet(item: $assigningNote) { note in
AssignProjectSheet(note: note)
}
}
}
@@ -90,6 +97,14 @@ struct NotesListView: View {
.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)
@@ -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()
}
}
}
+28
View File
@@ -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"
}
}
}
+130
View File
@@ -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)
}
}
}
+11
View File
@@ -7,6 +7,8 @@ struct SettingsView: View {
@Environment(ContextService.self) private var contextService
@EnvironmentObject private var backupController: BackupController
@AppStorage(ProjectsNaming.storageKey) private var namingRaw = ProjectsNaming.projects.rawValue
var body: some View {
NavigationStack {
Form {
@@ -44,6 +46,15 @@ struct SettingsView: View {
}
}
Section("Organization") {
// Renames the Projects tab plus its list headers and swipe/assign UI.
Picker("Group Notes By", selection: $namingRaw) {
ForEach(ProjectsNaming.allCases) { naming in
Text(naming.plural).tag(naming.rawValue)
}
}
}
Section("Context") {
ContextSummaryRow(
context: contextService.current,
+38
View File
@@ -137,6 +137,44 @@ struct NoteDocumentTests {
#expect(Note.audioRelativePath(forJSONPath: legacyJSONPath) == "1999/12/\(id.stringValue).m4a")
}
// MARK: - Schema v4 (project)
@Test func projectCodableRoundTrip() throws {
var context = CaptureContext.empty
context.device = "iPhone"
let note = Note.create(text: "Ship the release notes", context: context, project: "Contextful v0.3")
let data = try DocumentCoder.encode(note)
let decoded = try DocumentCoder.decode(Note.self, from: data)
#expect(decoded.payload == note.payload)
#expect(decoded.payload.project == "Contextful v0.3")
}
/// A v3 file predates the `project` key. It must decode unchanged as an
/// unfiled note with `project == nil` and pass the forward gate (3 current).
@Test func v3DocumentDecodesWithNoProject() throws {
let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 7, randomLo: 8)
let json = """
{
"meta": {
"id": "\(id.stringValue)",
"schemaVersion": 3,
"createdAt": "2023-11-14T22:13:20Z",
"modifiedAt": "2023-11-14T22:13:20Z"
},
"payload": {
"text": "A v3 note",
"source": "typed",
"tags": [],
"context": { "device": "iPhone", "timeZoneID": "UTC" }
}
}
"""
let note = try DocumentCoder.decode(Note.self, from: Data(json.utf8))
#expect(note.meta.schemaVersion == 3)
#expect(note.payload.project == nil)
#expect(note.isReadable)
}
@Test func createIngestedDerivesCreatedAtFromULID() {
let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 5, randomLo: 6)
var context = CaptureContext.empty
+15
View File
@@ -43,6 +43,21 @@ struct NoteMapperTests {
#expect(all.first?.tags == ["coffee", "people"])
}
@Test func upsertWritesProjectAndNoteRestoresIt() throws {
let (container, context) = try makeStore()
defer { withExtendedLifetime(container) {} }
var note = makeNote()
note.payload.project = "Kitchen Remodel"
NoteMapper.upsert(note, relativePath: note.relativePath, into: context)
let entity = try #require(NoteMapper.fetch(id: note.meta.id.stringValue, in: context))
#expect(entity.project == "Kitchen Remodel")
let restored = try #require(NoteMapper.note(from: entity))
#expect(restored.payload.project == "Kitchen Remodel")
#expect(restored.payload == note.payload)
}
@Test func entityRoundTripsBackToNote() throws {
let (container, context) = try makeStore()
defer { withExtendedLifetime(container) {} }
+1
View File
@@ -16,6 +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
- **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