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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user