Initial scaffold: context-aware notes app for iOS + macOS

XcodeGen project (Notes / NotesMac / NotesTests), Swift 6 strict concurrency,
iCloud-document storage via IndieSync with a SwiftData cache, automatic
capture context (location, place, time zone, device) on every note,
context-based relevance ranking with a 'Right here, right now' shelf,
tags, search, and the square.and.pencil-on-red app icon.
This commit is contained in:
2026-07-14 20:30:46 -04:00
commit 9b231a1978
42 changed files with 2126 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
import SwiftUI
struct ContentView: View {
@Environment(SyncEngine.self) private var syncEngine
var body: some View {
if syncEngine.iCloudStatus == .unavailable {
ICloudGateView()
} else {
TabView {
Tab("Notes", systemImage: "square.and.pencil") {
NotesListView()
}
Tab("Tags", systemImage: "tag") {
TagsView()
}
Tab("Settings", systemImage: "gearshape") {
SettingsView()
}
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import SwiftUI
/// Blocking screen when the ubiquity container can't be obtained. The app
/// deliberately has no local-only fallback notes written without iCloud
/// would exist nowhere durable and be wiped by the next cache rebuild.
struct ICloudGateView: View {
@Environment(SyncEngine.self) private var syncEngine
var body: some View {
VStack(spacing: 16) {
Image(systemName: "icloud.slash")
.font(.system(size: 56))
.foregroundStyle(.secondary)
Text("iCloud Required")
.font(.title2.bold())
Text("Notes stores everything in your iCloud Drive so it's on all your devices. Sign in to iCloud and enable iCloud Drive for Notes in Settings, then try again.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 420)
Button("Try Again") {
Task { await syncEngine.connect() }
}
.buttonStyle(.borderedProminent)
}
.padding(32)
}
}
+155
View File
@@ -0,0 +1,155 @@
import IndieSync
import SwiftUI
/// Composer for a new note and editor for an existing one. On create it
/// snapshots the current context (refreshing location in the background);
/// on edit the original capture context is preserved untouched.
struct NoteEditorView: View {
enum Mode {
case create
case edit(NoteEntity)
}
let mode: Mode
@Environment(SyncEngine.self) private var syncEngine
@Environment(ContextService.self) private var contextService
@Environment(\.dismiss) private var dismiss
@State private var text = ""
@State private var tagsText = ""
@State private var saveError: String?
@FocusState private var textFocused: Bool
var body: some View {
NavigationStack {
Form {
Section {
TextEditor(text: $text)
.focused($textFocused)
.frame(minHeight: 160)
}
Section("Tags") {
TextField("coffee, people, ideas…", text: $tagsText)
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
}
Section("Context") {
if isCreating {
ContextSummaryRow(
context: contextService.current,
isRefreshing: contextService.isRefreshing
)
} else if case .edit(let note) = mode {
ContextSummaryRow(context: note.context, isRefreshing: false)
}
}
if let saveError {
Section {
Label(saveError, systemImage: "exclamationmark.icloud")
.foregroundStyle(.red)
.font(.footnote)
}
}
}
.formStyle(.grouped)
.navigationTitle(isCreating ? "New Note" : "Edit Note")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
.task {
switch mode {
case .create:
textFocused = true
await contextService.refresh()
case .edit(let note):
text = note.text
tagsText = note.tags.joined(separator: ", ")
}
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 420)
#endif
}
private var isCreating: Bool {
if case .create = mode { return true }
return false
}
private var parsedTags: [String] {
tagsText
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
.filter { !$0.isEmpty }
}
private func save() {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
Task {
do {
switch mode {
case .create:
try await syncEngine.createNote(
text: trimmed,
tags: parsedTags,
context: contextService.current
)
case .edit(let entity):
guard let id = ULID(string: entity.id),
var note = await syncEngine.loadNote(id: id) else { return }
note.payload.text = trimmed
note.payload.tags = parsedTags
try await syncEngine.updateNote(note)
}
dismiss()
} catch {
saveError = "Couldn't save: \(error.localizedDescription)"
}
}
}
}
/// One-line "where and when" readout shown in the composer, so the user sees
/// what context is being stamped onto the note.
struct ContextSummaryRow: View {
let context: CaptureContext
let isRefreshing: Bool
var body: some View {
HStack {
Image(systemName: context.hasCoordinate ? "location.fill" : "location.slash")
.foregroundStyle(context.hasCoordinate ? Color.accentColor : .secondary)
if let summary = context.summary {
Text(summary)
} else if isRefreshing {
Text("Finding where you are…")
.foregroundStyle(.secondary)
} else if context.hasCoordinate {
Text("Location captured")
} else {
Text("No location")
.foregroundStyle(.secondary)
}
Spacer()
if isRefreshing {
ProgressView()
.controlSize(.small)
}
}
.font(.callout)
}
}
+32
View File
@@ -0,0 +1,32 @@
import SwiftUI
struct NoteRowView: View {
let note: NoteEntity
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(note.title.isEmpty ? "Untitled" : note.title)
.font(.body.weight(.medium))
.lineLimit(2)
HStack(spacing: 4) {
Text(note.createdAt, format: .dateTime.day().month().year())
if let place = note.context.summary {
Text("·")
Text(place)
.lineLimit(1)
}
}
.font(.caption)
.foregroundStyle(.secondary)
if !note.tags.isEmpty {
Text(note.tags.map { "#\($0)" }.joined(separator: " "))
.font(.caption)
.foregroundStyle(.tint)
.lineLimit(1)
}
}
.padding(.vertical, 2)
}
}
+117
View File
@@ -0,0 +1,117 @@
import IndieSync
import SwiftData
import SwiftUI
/// The main list: a "Right here, right now" shelf of context-relevant notes
/// on top, then everything else newest-first. Search filters text and tags.
struct NotesListView: View {
@Environment(SyncEngine.self) private var syncEngine
@Environment(ContextService.self) private var contextService
@Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity]
@State private var searchText = ""
@State private var composing = false
@State private var editingNote: NoteEntity?
var body: some View {
NavigationStack {
List {
if let error = syncEngine.lastSyncError {
Section {
Label(error, systemImage: "exclamationmark.icloud")
.foregroundStyle(.red)
.font(.footnote)
}
}
if searchText.isEmpty, !relevantNotes.isEmpty {
Section("Right here, right now") {
ForEach(relevantNotes) { note in
row(note)
}
}
}
Section(searchText.isEmpty ? "All notes" : "Results") {
ForEach(filteredNotes) { note in
row(note)
}
}
}
.navigationTitle("Notes")
.searchable(text: $searchText, prompt: "Search notes and tags")
.overlay {
if notes.isEmpty {
ContentUnavailableView(
"No notes yet",
systemImage: "square.and.pencil",
description: Text("Jot something down — Notes remembers where and when, so it can resurface at the right moment.")
)
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
composing = true
} label: {
Label("New Note", systemImage: "square.and.pencil")
}
.keyboardShortcut("n", modifiers: .command)
}
}
.sheet(isPresented: $composing) {
NoteEditorView(mode: .create)
}
.sheet(item: $editingNote) { note in
NoteEditorView(mode: .edit(note))
}
}
}
private func row(_ note: NoteEntity) -> some View {
Button {
editingNote = note
} label: {
NoteRowView(note: note)
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
delete(note)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
/// Top context matches notes whose capture context resembles "here and
/// now" strongly enough to earn the shelf.
private var relevantNotes: [NoteEntity] {
let query = RelevanceQuery(context: contextService)
return notes
.filter { $0.isLive }
.map { (note: $0, score: Relevance.score(RelevanceSignals(entity: $0), query: query)) }
.filter { $0.score >= Relevance.surfacingThreshold }
.sorted { $0.score > $1.score }
.prefix(5)
.map(\.note)
}
private var filteredNotes: [NoteEntity] {
guard !searchText.isEmpty else { return notes }
let needle = searchText.lowercased()
return notes.filter { note in
note.text.lowercased().contains(needle)
|| note.tags.contains { $0.lowercased().contains(needle) }
|| (note.placeName?.lowercased().contains(needle) ?? false)
|| (note.locality?.lowercased().contains(needle) ?? false)
}
}
private func delete(_ note: NoteEntity) {
guard let id = ULID(string: note.id) else { return }
Task {
try? await syncEngine.deleteNote(id: id)
}
}
}
+56
View File
@@ -0,0 +1,56 @@
import SwiftUI
struct SettingsView: View {
@Environment(SyncEngine.self) private var syncEngine
@Environment(ContextService.self) private var contextService
var body: some View {
NavigationStack {
Form {
Section("iCloud") {
HStack {
Text("Status")
Spacer()
switch syncEngine.iCloudStatus {
case .checking:
Text("Checking…").foregroundStyle(.secondary)
case .available:
Label("Connected", systemImage: "checkmark.icloud")
.foregroundStyle(.green)
case .unavailable:
Label("Unavailable", systemImage: "xmark.icloud")
.foregroundStyle(.red)
}
}
if let error = syncEngine.lastSyncError {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
}
Button("Rebuild Local Cache") {
Task { await syncEngine.rebuildCache() }
}
.disabled(syncEngine.iCloudStatus != .available || syncEngine.isSyncing)
}
Section("Context") {
ContextSummaryRow(
context: contextService.current,
isRefreshing: contextService.isRefreshing
)
if contextService.authorizationDenied {
Text("Location access is off. Notes can still be taken, but they won't resurface by place. Enable location for Notes in Settings.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Button("Refresh Context") {
Task { await contextService.refresh() }
}
.disabled(contextService.isRefreshing)
}
}
.formStyle(.grouped)
.navigationTitle("Settings")
}
}
}
+75
View File
@@ -0,0 +1,75 @@
import SwiftData
import SwiftUI
/// Browse notes by tag the manual-organization counterpart to the
/// automatic context ranking.
struct TagsView: View {
@Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity]
var body: some View {
NavigationStack {
List {
ForEach(tagCounts, id: \.tag) { entry in
NavigationLink(value: entry.tag) {
HStack {
Label(entry.tag, systemImage: "tag")
Spacer()
Text("\(entry.count)")
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
}
}
.navigationTitle("Tags")
.navigationDestination(for: String.self) { tag in
TaggedNotesView(tag: tag)
}
.overlay {
if tagCounts.isEmpty {
ContentUnavailableView(
"No tags yet",
systemImage: "tag",
description: Text("Add tags when writing a note to organize by topic.")
)
}
}
}
}
private var tagCounts: [(tag: String, count: Int)] {
var counts: [String: Int] = [:]
for note in notes where note.isLive {
for tag in note.tags {
counts[tag, default: 0] += 1
}
}
return counts
.map { (tag: $0.key, count: $0.value) }
.sorted { $0.count == $1.count ? $0.tag < $1.tag : $0.count > $1.count }
}
}
struct TaggedNotesView: View {
let tag: String
@Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity]
@State private var editingNote: NoteEntity?
var body: some View {
List {
ForEach(notes.filter { $0.isLive && $0.tags.contains(tag) }) { note in
Button {
editingNote = note
} label: {
NoteRowView(note: note)
}
.buttonStyle(.plain)
}
}
.navigationTitle("#\(tag)")
.sheet(item: $editingNote) { note in
NoteEditorView(mode: .edit(note))
}
}
}