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.
@@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Owns all service objects; created asynchronously at launch and injected
|
||||
/// into the SwiftUI environment.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class AppServices {
|
||||
let modelContainer: ModelContainer
|
||||
let syncEngine: SyncEngine
|
||||
let contextService: ContextService
|
||||
|
||||
init() async {
|
||||
let container = NotesModelContainer.make()
|
||||
self.modelContainer = container
|
||||
self.syncEngine = SyncEngine(modelContainer: container)
|
||||
self.contextService = ContextService()
|
||||
}
|
||||
|
||||
/// Slow startup work (iCloud container discovery) — runs after the UI is up.
|
||||
func startDeferredServices() async {
|
||||
await syncEngine.connect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.282",
|
||||
"green" : "0.208",
|
||||
"red" : "0.886"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "icon-ios-1024.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename": "icon-mac-16.png",
|
||||
"idiom": "mac",
|
||||
"size": "16x16",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"filename": "[email protected]",
|
||||
"idiom": "mac",
|
||||
"size": "16x16",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-mac-32.png",
|
||||
"idiom": "mac",
|
||||
"size": "32x32",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"filename": "[email protected]",
|
||||
"idiom": "mac",
|
||||
"size": "32x32",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-mac-128.png",
|
||||
"idiom": "mac",
|
||||
"size": "128x128",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"filename": "[email protected]",
|
||||
"idiom": "mac",
|
||||
"size": "128x128",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-mac-256.png",
|
||||
"idiom": "mac",
|
||||
"size": "256x256",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"filename": "[email protected]",
|
||||
"idiom": "mac",
|
||||
"size": "256x256",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-mac-512.png",
|
||||
"idiom": "mac",
|
||||
"size": "512x512",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"filename": "[email protected]",
|
||||
"idiom": "mac",
|
||||
"size": "512x512",
|
||||
"scale": "2x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 529 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 525 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 574 KiB |
@@ -0,0 +1 @@
|
||||
{"info":{"version":1,"author":"xcode"}}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import IndieSync
|
||||
|
||||
/// The context snapshot captured at the moment a note is taken — the raw
|
||||
/// material the relevance ranking later matches against "where am I, what
|
||||
/// time is it" at recall time. Every field is optional: capture is
|
||||
/// best-effort and a note taken with location off is still a note.
|
||||
struct CaptureContext: Codable, Sendable, Equatable {
|
||||
var latitude: Double?
|
||||
var longitude: Double?
|
||||
var horizontalAccuracy: Double?
|
||||
|
||||
/// Point-of-interest or venue name from reverse geocoding ("Blue Bottle
|
||||
/// Coffee"). Stored as text so recall works even when geocoding is
|
||||
/// unavailable later.
|
||||
var placeName: String?
|
||||
var thoroughfare: String?
|
||||
var locality: String?
|
||||
var administrativeArea: String?
|
||||
var country: String?
|
||||
|
||||
/// Time zone the note was taken in; local hour / weekday are derived
|
||||
/// from `meta.createdAt` under this zone rather than stored.
|
||||
var timeZoneID: String?
|
||||
|
||||
/// "iPhone" / "Mac" — which device captured the note.
|
||||
var device: String?
|
||||
|
||||
static let empty = CaptureContext()
|
||||
|
||||
var hasCoordinate: Bool { latitude != nil && longitude != nil }
|
||||
|
||||
/// Short human summary for rows and the composer ("Blue Bottle Coffee · Oakland").
|
||||
var summary: String? {
|
||||
let parts = [placeName, locality].compactMap { $0 }
|
||||
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
/// One note = one JSON file in the iCloud container, filed under
|
||||
/// `Records/YYYY/MM/<ULID>.json` (UTC-bucketed by the ULID's timestamp).
|
||||
struct Note: SyncDocument, VersionedDocument, Equatable {
|
||||
static let currentSchemaVersion = 1
|
||||
|
||||
struct Meta: Codable, Sendable, Equatable {
|
||||
var id: ULID
|
||||
var schemaVersion: Int
|
||||
var createdAt: Date
|
||||
var modifiedAt: Date
|
||||
}
|
||||
|
||||
/// How the note's text came to be. `transcribed` is reserved for the
|
||||
/// planned voice-capture path.
|
||||
enum Source: String, Codable, Sendable {
|
||||
case typed
|
||||
case transcribed
|
||||
}
|
||||
|
||||
struct Payload: Codable, Sendable, Equatable {
|
||||
var text: String
|
||||
var source: Source
|
||||
var tags: [String]
|
||||
var context: CaptureContext
|
||||
}
|
||||
|
||||
var meta: Meta
|
||||
var payload: Payload
|
||||
|
||||
var id: ULID { meta.id }
|
||||
var schemaVersion: Int { meta.schemaVersion }
|
||||
|
||||
var relativePath: String { Self.relativePath(forID: meta.id) }
|
||||
|
||||
static func relativePath(forID id: ULID) -> String {
|
||||
TimeBucketedLayout.relativePath(for: id)
|
||||
}
|
||||
|
||||
static func create(
|
||||
text: String,
|
||||
source: Source = .typed,
|
||||
tags: [String] = [],
|
||||
context: CaptureContext
|
||||
) -> 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)
|
||||
)
|
||||
}
|
||||
|
||||
/// First line of the text, used as the display title.
|
||||
var title: String {
|
||||
payload.text
|
||||
.split(separator: "\n", omittingEmptySubsequences: true)
|
||||
.first.map(String.init) ?? ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Rebuildable cache row for one note. Holds nothing that isn't also, more
|
||||
/// authoritatively, in the note's JSON file — the metadata observer (via
|
||||
/// `NoteMapper`) is the only writer.
|
||||
@Model
|
||||
final class NoteEntity {
|
||||
@Attribute(.unique) var id: String = ""
|
||||
var text: String = ""
|
||||
var sourceRaw: String = Note.Source.typed.rawValue
|
||||
var tags: [String] = []
|
||||
var createdAt: Date = Date()
|
||||
var modifiedAt: Date = Date()
|
||||
|
||||
/// Back-pointer to the authoritative file, so a "removed" event can find
|
||||
/// the row to delete by path when it lacks the id.
|
||||
var jsonRelativePath: String = ""
|
||||
|
||||
// Flattened CaptureContext — @Model can't store a nested Codable struct
|
||||
// as one column; call sites use the `context` computed property instead.
|
||||
var latitude: Double?
|
||||
var longitude: Double?
|
||||
var horizontalAccuracy: Double?
|
||||
var placeName: String?
|
||||
var thoroughfare: String?
|
||||
var locality: String?
|
||||
var administrativeArea: String?
|
||||
var country: String?
|
||||
var timeZoneID: String?
|
||||
var device: String?
|
||||
|
||||
init(id: String, jsonRelativePath: String) {
|
||||
self.id = id
|
||||
self.jsonRelativePath = jsonRelativePath
|
||||
}
|
||||
}
|
||||
|
||||
extension NoteEntity {
|
||||
var context: CaptureContext {
|
||||
get {
|
||||
CaptureContext(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
horizontalAccuracy: horizontalAccuracy,
|
||||
placeName: placeName,
|
||||
thoroughfare: thoroughfare,
|
||||
locality: locality,
|
||||
administrativeArea: administrativeArea,
|
||||
country: country,
|
||||
timeZoneID: timeZoneID,
|
||||
device: device
|
||||
)
|
||||
}
|
||||
set {
|
||||
latitude = newValue.latitude
|
||||
longitude = newValue.longitude
|
||||
horizontalAccuracy = newValue.horizontalAccuracy
|
||||
placeName = newValue.placeName
|
||||
thoroughfare = newValue.thoroughfare
|
||||
locality = newValue.locality
|
||||
administrativeArea = newValue.administrativeArea
|
||||
country = newValue.country
|
||||
timeZoneID = newValue.timeZoneID
|
||||
device = newValue.device
|
||||
}
|
||||
}
|
||||
|
||||
var source: Note.Source {
|
||||
Note.Source(rawValue: sourceRaw) ?? .typed
|
||||
}
|
||||
|
||||
/// First line of the text, used as the display title.
|
||||
var title: String {
|
||||
text.split(separator: "\n", omittingEmptySubsequences: true)
|
||||
.first.map(String.init) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
extension PersistentModel {
|
||||
/// Safe-to-read guard for objects reached through `@Query` or a
|
||||
/// relationship during a rebuild/sync burst. `isDeleted` alone only
|
||||
/// covers the unsaved window.
|
||||
var isLive: Bool { modelContext != nil && !isDeleted }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
import IndieSync
|
||||
import SwiftData
|
||||
|
||||
/// Document ↔ cache-entity mapping. Nothing else in the app knows both
|
||||
/// shapes. `upsert` is used only by the metadata observer and cache
|
||||
/// rebuilds — app code never writes the cache directly.
|
||||
enum NoteMapper {
|
||||
static func fetch(id: String, in context: ModelContext) -> NoteEntity? {
|
||||
var descriptor = FetchDescriptor<NoteEntity>(predicate: #Predicate { $0.id == id })
|
||||
descriptor.fetchLimit = 1
|
||||
return try? context.fetch(descriptor).first
|
||||
}
|
||||
|
||||
static func fetch(jsonRelativePath path: String, in context: ModelContext) -> [NoteEntity] {
|
||||
let descriptor = FetchDescriptor<NoteEntity>(predicate: #Predicate { $0.jsonRelativePath == path })
|
||||
return (try? context.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
static func upsert(_ note: Note, relativePath: String, into context: ModelContext) {
|
||||
let idString = note.meta.id.stringValue
|
||||
let entity: NoteEntity
|
||||
if let existing = fetch(id: idString, in: context) {
|
||||
entity = existing
|
||||
} else {
|
||||
entity = NoteEntity(id: idString, jsonRelativePath: relativePath)
|
||||
context.insert(entity)
|
||||
}
|
||||
// Always overwrite, not just on insert — the document just read is at
|
||||
// least as fresh as whatever the cache last held.
|
||||
entity.text = note.payload.text
|
||||
entity.sourceRaw = note.payload.source.rawValue
|
||||
entity.tags = note.payload.tags
|
||||
entity.createdAt = note.meta.createdAt
|
||||
entity.modifiedAt = note.meta.modifiedAt
|
||||
entity.jsonRelativePath = relativePath
|
||||
entity.context = note.payload.context
|
||||
}
|
||||
|
||||
/// Near-lossless reconstruction from the cache, used only as the offline
|
||||
/// fallback when the authoritative file can't be read.
|
||||
static func note(from entity: NoteEntity) -> Note? {
|
||||
guard let id = ULID(string: entity.id) else { return nil }
|
||||
return Note(
|
||||
meta: Note.Meta(
|
||||
id: id,
|
||||
schemaVersion: Note.currentSchemaVersion,
|
||||
createdAt: entity.createdAt,
|
||||
modifiedAt: entity.modifiedAt
|
||||
),
|
||||
payload: Note.Payload(
|
||||
text: entity.text,
|
||||
source: entity.source,
|
||||
tags: entity.tags,
|
||||
context: entity.context
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Creates the SwiftData cache container. The store is disposable: schema
|
||||
/// changes and iCloud account changes wipe it, and `SyncEngine` rebuilds it
|
||||
/// from the files (an empty cache is the entire contract between the two).
|
||||
enum NotesModelContainer {
|
||||
/// Bump whenever the cache schema changes — wipes and rebuilds from files.
|
||||
static let cacheSchemaVersion = 1
|
||||
private static let cacheSchemaVersionKey = "Notes.cacheSchemaVersion"
|
||||
private static let identityTokenKey = "Notes.iCloudIdentityToken"
|
||||
|
||||
private static var storeURL: URL {
|
||||
URL.applicationSupportDirectory.appending(path: "Notes.store")
|
||||
}
|
||||
|
||||
static func make(inMemory: Bool = false) -> ModelContainer {
|
||||
let schema = Schema([NoteEntity.self])
|
||||
|
||||
if !inMemory {
|
||||
try? FileManager.default.createDirectory(
|
||||
at: storeURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
wipeIfNeeded()
|
||||
wipeIfAccountChanged()
|
||||
|
||||
do {
|
||||
// `cloudKitDatabase: .none` is load-bearing: the parameter
|
||||
// defaults to `.automatic`, which — because the app is
|
||||
// iCloud-entitled — would silently mirror the cache to
|
||||
// CloudKit on top of the file-based sync.
|
||||
let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none)
|
||||
let container = try ModelContainer(for: schema, configurations: [config])
|
||||
UserDefaults.standard.set(cacheSchemaVersion, forKey: cacheSchemaVersionKey)
|
||||
return container
|
||||
} catch {
|
||||
print("Notes: ModelContainer creation failed at \(storeURL.path): \(error). Falling back to in-memory.")
|
||||
}
|
||||
}
|
||||
|
||||
// In-memory: screenshot/test mode, or last resort so the app still
|
||||
// launches (degraded, cache-only for the session) rather than crashing.
|
||||
do {
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)
|
||||
return try ModelContainer(for: schema, configurations: [config])
|
||||
} catch {
|
||||
fatalError("Notes: could not create even an in-memory ModelContainer: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func wipeIfNeeded() {
|
||||
let stored = UserDefaults.standard.integer(forKey: cacheSchemaVersionKey)
|
||||
guard stored < cacheSchemaVersion else { return }
|
||||
wipeStore()
|
||||
}
|
||||
|
||||
/// Wipes when the signed-in iCloud account's ubiquity identity token
|
||||
/// differs from the one the cache was built for. A nil token is not a
|
||||
/// change — it's legitimately nil for the same account early in launch.
|
||||
private static func wipeIfAccountChanged() {
|
||||
guard let current = currentIdentityTokenData() else { return }
|
||||
let stored = UserDefaults.standard.data(forKey: identityTokenKey)
|
||||
guard current != stored else { return }
|
||||
wipeStore()
|
||||
UserDefaults.standard.set(current, forKey: identityTokenKey)
|
||||
}
|
||||
|
||||
private static func currentIdentityTokenData() -> Data? {
|
||||
guard let token = FileManager.default.ubiquityIdentityToken else { return nil }
|
||||
return try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: false)
|
||||
}
|
||||
|
||||
/// Removes the on-disk store and its SQLite WAL/SHM sidecars. Safe
|
||||
/// because the store is a rebuildable cache.
|
||||
private static func wipeStore() {
|
||||
for url in [storeURL, URL(fileURLWithPath: storeURL.path + "-wal"), URL(fileURLWithPath: storeURL.path + "-shm")] {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct NotesApp: App {
|
||||
@State private var appServices: AppServices?
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Group {
|
||||
if let appServices {
|
||||
ContentView()
|
||||
.modelContainer(appServices.modelContainer)
|
||||
.environment(appServices.syncEngine)
|
||||
.environment(appServices.contextService)
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if appServices == nil {
|
||||
let services = await AppServices()
|
||||
appServices = services
|
||||
await services.startDeferredServices()
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
guard phase == .active, let appServices else { return }
|
||||
let syncEngine = appServices.syncEngine
|
||||
let contextService = appServices.contextService
|
||||
Task {
|
||||
// Fresh context whenever the user comes back to the app —
|
||||
// recall ranking should reflect where they are right now.
|
||||
await contextService.refresh()
|
||||
}
|
||||
Task {
|
||||
switch syncEngine.iCloudStatus {
|
||||
case .unavailable:
|
||||
// The user typically fixes iCloud in Settings and
|
||||
// switches straight back — retry on foreground.
|
||||
await syncEngine.connect()
|
||||
case .available:
|
||||
// Pull in records that synced down (and drop ones
|
||||
// deleted elsewhere) while the app was backgrounded.
|
||||
await syncEngine.reconcile()
|
||||
case .checking:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.defaultSize(width: 900, height: 700)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Notes</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Notes remembers where each note was taken, so the right notes can resurface when you return to the same place.</string>
|
||||
<key>NSUbiquitousContainers</key>
|
||||
<dict>
|
||||
<key>iCloud.dev.rzen.indie.Notes</key>
|
||||
<dict>
|
||||
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
|
||||
<true/>
|
||||
<key>NSUbiquitousContainerSupportedFolderLevels</key>
|
||||
<string>Any</string>
|
||||
<key>NSUbiquitousContainerName</key>
|
||||
<string>Notes</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Notes</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.productivity</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Notes remembers where each note was taken, so the right notes can resurface when you return to the same place.</string>
|
||||
<key>NSLocationUsageDescription</key>
|
||||
<string>Notes remembers where each note was taken, so the right notes can resurface when you return to the same place.</string>
|
||||
<key>NSUbiquitousContainers</key>
|
||||
<dict>
|
||||
<key>iCloud.dev.rzen.indie.Notes</key>
|
||||
<dict>
|
||||
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
|
||||
<true/>
|
||||
<key>NSUbiquitousContainerSupportedFolderLevels</key>
|
||||
<string>Any</string>
|
||||
<key>NSUbiquitousContainerName</key>
|
||||
<string>Notes</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.dev.rzen.indie.Notes</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudDocuments</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.dev.rzen.indie.Notes</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.dev.rzen.indie.Notes</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudDocuments</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.dev.rzen.indie.Notes</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,96 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import MapKit
|
||||
|
||||
/// Captures the "where am I, what time is it" snapshot — used both when a
|
||||
/// note is taken (stamped into the note) and when notes are recalled (fed to
|
||||
/// the relevance ranking). Location is best-effort: denied or unavailable
|
||||
/// location still yields a usable context (time zone, device).
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ContextService {
|
||||
/// Latest context snapshot. Updated by `refresh()`.
|
||||
private(set) var current: CaptureContext = .empty
|
||||
|
||||
/// Raw location behind `current`, for distance math at recall time.
|
||||
private(set) var location: CLLocation?
|
||||
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var authorizationDenied = false
|
||||
|
||||
init() {
|
||||
current.device = Self.deviceName
|
||||
current.timeZoneID = TimeZone.current.identifier
|
||||
}
|
||||
|
||||
static var deviceName: String {
|
||||
#if os(macOS)
|
||||
"Mac"
|
||||
#else
|
||||
"iPhone"
|
||||
#endif
|
||||
}
|
||||
|
||||
/// One-shot refresh: grab a current location (asking for when-in-use
|
||||
/// authorization if needed), then reverse-geocode it into place names.
|
||||
/// Bounded — gives up after ~10 seconds rather than spinning.
|
||||
func refresh() async {
|
||||
guard !isRefreshing else { return }
|
||||
isRefreshing = true
|
||||
defer { isRefreshing = false }
|
||||
|
||||
current.device = Self.deviceName
|
||||
current.timeZoneID = TimeZone.current.identifier
|
||||
|
||||
guard let location = await acquireLocation() else { return }
|
||||
self.location = location
|
||||
current.latitude = location.coordinate.latitude
|
||||
current.longitude = location.coordinate.longitude
|
||||
current.horizontalAccuracy = location.horizontalAccuracy
|
||||
|
||||
await reverseGeocode(location)
|
||||
}
|
||||
|
||||
/// First fix from `CLLocationUpdate.liveUpdates()`, raced against a
|
||||
/// 10-second deadline. `CLServiceSession` holds the when-in-use
|
||||
/// authorization request open for the duration.
|
||||
private func acquireLocation() async -> CLLocation? {
|
||||
// CLServiceSession is unavailable on macOS; there liveUpdates itself
|
||||
// triggers the authorization prompt.
|
||||
#if os(iOS)
|
||||
let session = CLServiceSession(authorization: .whenInUse)
|
||||
defer { session.invalidate() }
|
||||
#endif
|
||||
|
||||
let fix = Task {
|
||||
for try await update in CLLocationUpdate.liveUpdates() {
|
||||
if update.authorizationDenied || update.authorizationDeniedGlobally {
|
||||
await MainActor.run { self.authorizationDenied = true }
|
||||
return nil as CLLocation?
|
||||
}
|
||||
if let location = update.location {
|
||||
return location
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
let deadline = Task {
|
||||
try? await Task.sleep(for: .seconds(10))
|
||||
fix.cancel()
|
||||
}
|
||||
defer { deadline.cancel() }
|
||||
|
||||
let location = (try? await fix.value) ?? nil
|
||||
if location != nil { authorizationDenied = false }
|
||||
return location
|
||||
}
|
||||
|
||||
private func reverseGeocode(_ location: CLLocation) async {
|
||||
guard let request = MKReverseGeocodingRequest(location: location) else { return }
|
||||
guard let item = try? await request.mapItems.first else { return }
|
||||
|
||||
current.placeName = item.name
|
||||
current.thoroughfare = item.address?.shortAddress
|
||||
current.locality = item.addressRepresentations?.cityName
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
/// The signals relevance scoring reads from a note. A plain value type so
|
||||
/// scoring is testable without SwiftData.
|
||||
struct RelevanceSignals: Sendable, Equatable {
|
||||
var latitude: Double?
|
||||
var longitude: Double?
|
||||
var placeName: String?
|
||||
var locality: String?
|
||||
var createdAt: Date
|
||||
var timeZoneID: String?
|
||||
}
|
||||
|
||||
/// The recall-time context a query scores against.
|
||||
struct RelevanceQuery: Sendable {
|
||||
var latitude: Double?
|
||||
var longitude: Double?
|
||||
var placeName: String?
|
||||
var locality: String?
|
||||
var date: Date = Date()
|
||||
var timeZone: TimeZone = .current
|
||||
}
|
||||
|
||||
/// Context-based ranking: given "where am I, what time is it" now, how likely
|
||||
/// is this note the one the user wants at their fingertips?
|
||||
///
|
||||
/// The score is a sum of independent affinity terms, each decaying smoothly
|
||||
/// from its ideal (same spot, same hour, just written). Weights are tuned so
|
||||
/// physical proximity dominates — being back at the coffee shop should
|
||||
/// surface the barista's name even if the note is months old.
|
||||
enum Relevance {
|
||||
/// Score at or above which a note belongs in the "right here, right now"
|
||||
/// shelf.
|
||||
static let surfacingThreshold = 1.0
|
||||
|
||||
static func score(_ note: RelevanceSignals, query: RelevanceQuery) -> Double {
|
||||
var score = 0.0
|
||||
|
||||
// Proximity: exponential decay with ~250 m scale — full weight inside
|
||||
// a venue, negligible a few kilometers away.
|
||||
if let noteLat = note.latitude, let noteLon = note.longitude,
|
||||
let hereLat = query.latitude, let hereLon = query.longitude {
|
||||
let meters = CLLocation(latitude: noteLat, longitude: noteLon)
|
||||
.distance(from: CLLocation(latitude: hereLat, longitude: hereLon))
|
||||
score += 3.0 * exp(-meters / 250)
|
||||
}
|
||||
|
||||
// Same named place (exact, case-insensitive) — reverse geocoding is
|
||||
// stable enough that this is a strong "you are back here" signal even
|
||||
// when GPS wobbles.
|
||||
if let notePlace = note.placeName, let herePlace = query.placeName,
|
||||
!notePlace.isEmpty, notePlace.caseInsensitiveCompare(herePlace) == .orderedSame {
|
||||
score += 1.5
|
||||
} else if let noteCity = note.locality, let hereCity = query.locality,
|
||||
!noteCity.isEmpty, noteCity.caseInsensitiveCompare(hereCity) == .orderedSame {
|
||||
score += 0.3
|
||||
}
|
||||
|
||||
// Time-of-day affinity: Gaussian over circular hour distance
|
||||
// (σ ≈ 2.5 h) — a "morning" note resurfaces in the morning.
|
||||
let noteHour = localHour(of: note.createdAt, timeZoneID: note.timeZoneID)
|
||||
let queryHour = Double(Calendar.current.component(.hour, from: query.date))
|
||||
+ Double(Calendar.current.component(.minute, from: query.date)) / 60
|
||||
let hourDelta = circularHourDistance(noteHour, queryHour)
|
||||
score += 0.6 * exp(-(hourDelta * hourDelta) / (2 * 2.5 * 2.5))
|
||||
|
||||
// Same weekday: weak habitual signal (the Saturday farmers market).
|
||||
var noteCalendar = Calendar.current
|
||||
if let id = note.timeZoneID, let zone = TimeZone(identifier: id) {
|
||||
noteCalendar.timeZone = zone
|
||||
}
|
||||
var queryCalendar = Calendar.current
|
||||
queryCalendar.timeZone = query.timeZone
|
||||
if noteCalendar.component(.weekday, from: note.createdAt)
|
||||
== queryCalendar.component(.weekday, from: query.date) {
|
||||
score += 0.2
|
||||
}
|
||||
|
||||
// Recency: fresh notes float regardless of place (~45-day half-life
|
||||
// feel; purely a tiebreaker against the location terms).
|
||||
let ageDays = max(0, query.date.timeIntervalSince(note.createdAt)) / 86_400
|
||||
score += 0.5 * exp(-ageDays / 45)
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
/// Hour-of-day (fractional) of `date` in the zone the note was taken in,
|
||||
/// falling back to the current zone.
|
||||
private static func localHour(of date: Date, timeZoneID: String?) -> Double {
|
||||
var calendar = Calendar.current
|
||||
if let timeZoneID, let zone = TimeZone(identifier: timeZoneID) {
|
||||
calendar.timeZone = zone
|
||||
}
|
||||
let hour = calendar.component(.hour, from: date)
|
||||
let minute = calendar.component(.minute, from: date)
|
||||
return Double(hour) + Double(minute) / 60
|
||||
}
|
||||
|
||||
/// Distance between two hours on the 24-hour circle (0...12).
|
||||
private static func circularHourDistance(_ a: Double, _ b: Double) -> Double {
|
||||
let diff = abs(a - b).truncatingRemainder(dividingBy: 24)
|
||||
return min(diff, 24 - diff)
|
||||
}
|
||||
}
|
||||
|
||||
extension RelevanceSignals {
|
||||
init(entity: NoteEntity) {
|
||||
self.init(
|
||||
latitude: entity.latitude,
|
||||
longitude: entity.longitude,
|
||||
placeName: entity.placeName,
|
||||
locality: entity.locality,
|
||||
createdAt: entity.createdAt,
|
||||
timeZoneID: entity.timeZoneID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension RelevanceQuery {
|
||||
@MainActor
|
||||
init(context service: ContextService) {
|
||||
self.init(
|
||||
latitude: service.current.latitude,
|
||||
longitude: service.current.longitude,
|
||||
placeName: service.current.placeName,
|
||||
locality: service.current.locality
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import Foundation
|
||||
import IndieSync
|
||||
import SwiftData
|
||||
|
||||
enum ICloudStatus: Equatable, Sendable {
|
||||
case checking
|
||||
case available
|
||||
case unavailable
|
||||
}
|
||||
|
||||
/// Orchestrates iCloud JSON files (the sole source of truth) and the
|
||||
/// SwiftData cache. Also exposes the domain operations the UI calls: create,
|
||||
/// update, delete a note.
|
||||
///
|
||||
/// The file layer lives in the IndieSync package: `DocumentFileStore` for
|
||||
/// coordinated, conflict-resolved, eviction-safe I/O; `TombstoneStore` for
|
||||
/// soft-delete stubs; `MetadataObserver` for change events. Data flows one
|
||||
/// way — writes touch files only; the observers are the sole writers of the
|
||||
/// cache, so a local save reaches it through the same pipeline as a change
|
||||
/// from another device.
|
||||
///
|
||||
/// Layout inside the container (record paths in this engine are
|
||||
/// Records-relative and get prefixed via `dataPath(_:)` at the store
|
||||
/// boundary):
|
||||
/// ```
|
||||
/// Documents/
|
||||
/// Records/YYYY/MM/<ULID>.json
|
||||
/// Stubs/YYYY/MM/<ULID>.json ← soft-delete markers
|
||||
/// ```
|
||||
@Observable
|
||||
@MainActor
|
||||
final class SyncEngine {
|
||||
private(set) var iCloudStatus: ICloudStatus = .checking
|
||||
private(set) var isSyncing = false
|
||||
|
||||
/// Last non-fatal sync failure, surfaced for the UI to render. Clears on
|
||||
/// the next successful write or rebuild.
|
||||
private(set) var lastSyncError: String?
|
||||
|
||||
static let recordsDirectory = "Records"
|
||||
static let stubsDirectory = "Stubs"
|
||||
|
||||
/// Store-rooted path of a record (record paths are Records-relative
|
||||
/// everywhere inside this engine).
|
||||
static func dataPath(_ recordsRelativePath: String) -> String {
|
||||
"\(recordsDirectory)/\(recordsRelativePath)"
|
||||
}
|
||||
|
||||
private let modelContainer: ModelContainer
|
||||
private var store: DocumentFileStore?
|
||||
private var tombstones: TombstoneStore?
|
||||
private var dataMonitor: MetadataObserver?
|
||||
private var stubMonitor: MetadataObserver?
|
||||
private var dataMonitorTask: Task<Void, Never>?
|
||||
private var stubMonitorTask: Task<Void, Never>?
|
||||
|
||||
private var cacheContext: ModelContext { modelContainer.mainContext }
|
||||
|
||||
init(modelContainer: ModelContainer) {
|
||||
self.modelContainer = modelContainer
|
||||
}
|
||||
|
||||
// MARK: - Connection
|
||||
|
||||
private var connectAttempt = 0
|
||||
|
||||
/// Resolves the ubiquity container and connects. Safe to call repeatedly —
|
||||
/// the gate's "Try Again" button and foreground reactivation reuse it.
|
||||
func connect() async {
|
||||
guard iCloudStatus != .available else { return }
|
||||
connectAttempt += 1
|
||||
let attempt = connectAttempt
|
||||
iCloudStatus = .checking
|
||||
|
||||
// Blocking call — never on the main thread.
|
||||
let url = await Task.detached {
|
||||
FileManager.default.url(forUbiquityContainerIdentifier: nil)
|
||||
}.value
|
||||
|
||||
guard let containerURL = url else {
|
||||
if attempt == connectAttempt {
|
||||
iCloudStatus = .unavailable
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let store = DocumentFileStore(
|
||||
root: containerURL.appendingPathComponent("Documents", isDirectory: true))
|
||||
|
||||
// File ops inside the container can hang indefinitely while the
|
||||
// iCloud account is in a bad state — flip the gate to "unavailable"
|
||||
// after a timeout, but let the attempt keep running and finish
|
||||
// connecting if it eventually succeeds.
|
||||
let timeout = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(20))
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
if self.iCloudStatus == .checking, attempt == self.connectAttempt {
|
||||
self.iCloudStatus = .unavailable
|
||||
}
|
||||
}
|
||||
|
||||
await store.prepareDirectories([Self.recordsDirectory, Self.stubsDirectory])
|
||||
timeout.cancel()
|
||||
guard attempt == connectAttempt else { return } // superseded by a retry
|
||||
|
||||
self.store = store
|
||||
self.tombstones = TombstoneStore(store: store, stubsDirectory: Self.stubsDirectory)
|
||||
iCloudStatus = .available
|
||||
|
||||
startMonitoring()
|
||||
await syncCacheOnConnect()
|
||||
performLaunchMaintenance()
|
||||
}
|
||||
|
||||
// MARK: - Domain operations
|
||||
|
||||
@discardableResult
|
||||
func createNote(
|
||||
text: String,
|
||||
source: Note.Source = .typed,
|
||||
tags: [String] = [],
|
||||
context: CaptureContext
|
||||
) async throws -> Note {
|
||||
let note = Note.create(text: text, source: source, tags: tags, context: context)
|
||||
try await save(note)
|
||||
return note
|
||||
}
|
||||
|
||||
func updateNote(_ note: Note) async throws {
|
||||
var updated = note
|
||||
updated.meta.modifiedAt = Date()
|
||||
try await save(updated)
|
||||
}
|
||||
|
||||
func deleteNote(id: ULID) async throws {
|
||||
// Without the file layer only the cache row could go — and the iCloud
|
||||
// file would re-import on the next rebuild, resurrecting the note.
|
||||
// Refuse instead.
|
||||
guard let tombstones else {
|
||||
report("Delete failed — iCloud not connected")
|
||||
throw SyncError.iCloudUnavailable
|
||||
}
|
||||
let path = Note.relativePath(forID: id)
|
||||
try await tombstones.softDelete(dataRelativePath: Self.dataPath(path), at: path)
|
||||
lastSyncError = nil
|
||||
// Cache cleanup happens via the monitors (file removal + stub
|
||||
// appearance both drop the row), but do it eagerly too so the UI
|
||||
// updates instantly even while iCloud event delivery lags.
|
||||
removeFromCache(jsonRelativePath: path)
|
||||
}
|
||||
|
||||
/// Reads the JSON file — the authority — so read-after-write flows never
|
||||
/// act on a stale cache. Falls back to the near-lossless cache copy only
|
||||
/// if the file is unreadable (offline or not yet downloaded).
|
||||
func loadNote(id: ULID) async -> Note? {
|
||||
let path = Note.relativePath(forID: id)
|
||||
if let store,
|
||||
let note = try? await store.read(Note.self, from: Self.dataPath(path)),
|
||||
note.isReadable {
|
||||
return migrateIfNeeded(note)
|
||||
}
|
||||
guard let entity = NoteMapper.fetch(id: id.stringValue, in: cacheContext) else { return nil }
|
||||
return NoteMapper.note(from: entity)
|
||||
}
|
||||
|
||||
// MARK: - Persistence plumbing
|
||||
|
||||
/// Writes touch the file layer ONLY. iCloud JSON is the source of truth:
|
||||
/// with no file store the write would exist nowhere durable (a cache-only
|
||||
/// copy is wiped by the next rebuild), so refuse the save. The UI gate is
|
||||
/// a courtesy; this throw is the invariant.
|
||||
private func save(_ note: Note) async throws {
|
||||
guard let store else {
|
||||
report("Note not saved — iCloud not connected")
|
||||
throw SyncError.iCloudUnavailable
|
||||
}
|
||||
do {
|
||||
_ = try await store.write(note, to: Self.dataPath(note.relativePath))
|
||||
} catch {
|
||||
report("Failed to save note", error)
|
||||
throw error
|
||||
}
|
||||
lastSyncError = nil
|
||||
}
|
||||
|
||||
/// Record a non-fatal sync failure: log it and publish it as
|
||||
/// `lastSyncError` for the UI to render.
|
||||
private func report(_ message: String, _ error: Error? = nil) {
|
||||
let full = error.map { "\(message): \($0.localizedDescription)" } ?? message
|
||||
print("[Sync] \(full)")
|
||||
lastSyncError = full
|
||||
}
|
||||
|
||||
private func removeFromCache(jsonRelativePath: String) {
|
||||
let entities = NoteMapper.fetch(jsonRelativePath: jsonRelativePath, in: cacheContext)
|
||||
guard !entities.isEmpty else { return }
|
||||
entities.forEach(cacheContext.delete)
|
||||
try? cacheContext.save()
|
||||
}
|
||||
|
||||
private func removeFromCache(ids: Set<String>) {
|
||||
for id in ids {
|
||||
if let entity = NoteMapper.fetch(id: id, in: cacheContext) {
|
||||
cacheContext.delete(entity)
|
||||
}
|
||||
}
|
||||
try? cacheContext.save()
|
||||
}
|
||||
|
||||
// MARK: - Monitoring
|
||||
|
||||
private func startMonitoring() {
|
||||
guard let store else { return }
|
||||
|
||||
// Idempotent: tear down any existing monitors first so a reconnect
|
||||
// can't leave two live NSMetadataQuery monitors feeding duplicate events.
|
||||
stopMonitoring()
|
||||
|
||||
let recordsURL = store.rootURL.appendingPathComponent(Self.recordsDirectory, isDirectory: true)
|
||||
let dataMonitor = MetadataObserver(documentsURL: recordsURL)
|
||||
self.dataMonitor = dataMonitor
|
||||
dataMonitor.start()
|
||||
dataMonitorTask = Task { [weak self] in
|
||||
for await batch in dataMonitor.events() {
|
||||
for event in batch {
|
||||
await self?.handleFileEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stubsURL = store.rootURL.appendingPathComponent(Self.stubsDirectory, isDirectory: true)
|
||||
let stubMonitor = MetadataObserver(documentsURL: stubsURL)
|
||||
self.stubMonitor = stubMonitor
|
||||
stubMonitor.start()
|
||||
stubMonitorTask = Task { [weak self] in
|
||||
for await batch in stubMonitor.events() {
|
||||
for event in batch {
|
||||
if case .added(let path) = event {
|
||||
await self?.handleStubAdded(relativePath: path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopMonitoring() {
|
||||
dataMonitorTask?.cancel()
|
||||
stubMonitorTask?.cancel()
|
||||
dataMonitorTask = nil
|
||||
stubMonitorTask = nil
|
||||
dataMonitor?.stop()
|
||||
stubMonitor?.stop()
|
||||
dataMonitor = nil
|
||||
stubMonitor = nil
|
||||
}
|
||||
|
||||
private func handleFileEvent(_ event: FileChangeEvent) async {
|
||||
guard let store, let tombstones else { return }
|
||||
switch event {
|
||||
case .added(let path), .modified(let path):
|
||||
// Match stubs by document id, not path equality, so an evicted
|
||||
// stub still vetoes resurrection.
|
||||
let id = DocumentFileStore.documentID(fromRelativePath: path)
|
||||
if await tombstones.stubExists(id: id) {
|
||||
try? await store.remove(at: Self.dataPath(path))
|
||||
return
|
||||
}
|
||||
await importRecord(at: path, using: store)
|
||||
|
||||
case .removed(let path):
|
||||
removeFromCache(jsonRelativePath: path)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleStubAdded(relativePath: String) async {
|
||||
if let store {
|
||||
try? await store.remove(at: Self.dataPath(relativePath))
|
||||
}
|
||||
removeFromCache(jsonRelativePath: relativePath)
|
||||
}
|
||||
|
||||
private func importRecord(at path: String, using store: DocumentFileStore) async {
|
||||
// The store's read resolves conflicts and force-downloads evicted
|
||||
// files (bounded wait) before the coordinated read.
|
||||
guard let note = try? await store.read(Note.self, from: Self.dataPath(path)) else { return }
|
||||
// Forward gate: a document written by a newer app version is
|
||||
// quarantined — Codable would silently drop its unknown fields.
|
||||
guard note.isReadable else {
|
||||
print("[Sync] Skipping \(path) — schema \(note.schemaVersion) is newer than this app understands")
|
||||
return
|
||||
}
|
||||
NoteMapper.upsert(migrateIfNeeded(note), relativePath: path, into: cacheContext)
|
||||
try? cacheContext.save()
|
||||
}
|
||||
|
||||
// MARK: - Cache rebuild / reconcile
|
||||
|
||||
/// Bring the cache in line with iCloud on connect: full rebuild when the
|
||||
/// cache is empty (first launch, post-wipe), incremental reconcile
|
||||
/// otherwise — records synced down while the app was closed must appear.
|
||||
private func syncCacheOnConnect() async {
|
||||
guard store != nil else { return }
|
||||
let count = (try? cacheContext.fetchCount(FetchDescriptor<NoteEntity>())) ?? 0
|
||||
if count == 0 {
|
||||
await rebuildCache()
|
||||
} else {
|
||||
await reconcile()
|
||||
}
|
||||
}
|
||||
|
||||
/// Diff iCloud's files against the cache and settle the difference:
|
||||
/// import records that synced down while the app was closed/backgrounded,
|
||||
/// drop rows whose file was deleted elsewhere, and clean up files a stub
|
||||
/// has since tombstoned. Runs on every connect and foreground return.
|
||||
func reconcile() async {
|
||||
guard let store, let tombstones else { return }
|
||||
|
||||
let filePaths = await listRecordPaths(in: store)
|
||||
let stubIDs = await tombstones.listStubIDs()
|
||||
|
||||
var livePathsByID: [String: String] = [:]
|
||||
var orphanedPaths: [String] = []
|
||||
for path in filePaths {
|
||||
let id = DocumentFileStore.documentID(fromRelativePath: path)
|
||||
if stubIDs.contains(id) {
|
||||
orphanedPaths.append(path)
|
||||
} else {
|
||||
livePathsByID[id] = path
|
||||
}
|
||||
}
|
||||
|
||||
var descriptor = FetchDescriptor<NoteEntity>()
|
||||
descriptor.propertiesToFetch = [\.id]
|
||||
let cachedIDs = Set(((try? cacheContext.fetch(descriptor)) ?? []).map(\.id))
|
||||
|
||||
let toImport = Set(livePathsByID.keys).subtracting(cachedIDs)
|
||||
let toRemove = cachedIDs.subtracting(livePathsByID.keys)
|
||||
|
||||
guard !toImport.isEmpty || !toRemove.isEmpty || !orphanedPaths.isEmpty else {
|
||||
return // already in sync — the common foreground case, kept cheap
|
||||
}
|
||||
print("[Sync] Reconcile: +\(toImport.count)/-\(toRemove.count) notes, \(orphanedPaths.count) orphaned files")
|
||||
|
||||
isSyncing = true
|
||||
defer { isSyncing = false }
|
||||
|
||||
// Clean up files a stub has tombstoned (mirrors the rebuild path).
|
||||
for path in orphanedPaths {
|
||||
try? await store.remove(at: Self.dataPath(path))
|
||||
}
|
||||
|
||||
removeFromCache(ids: toRemove)
|
||||
|
||||
// Import the missing records through the same conflict-resolved,
|
||||
// eviction-safe, forward-gated path the monitors use.
|
||||
for id in toImport.sorted() {
|
||||
guard let path = livePathsByID[id] else { continue }
|
||||
await importRecord(at: path, using: store)
|
||||
}
|
||||
}
|
||||
|
||||
func rebuildCache() async {
|
||||
guard let store, let tombstones else { return }
|
||||
isSyncing = true
|
||||
defer { isSyncing = false }
|
||||
|
||||
await store.downloadEvictedFiles(inSubdirectory: Self.recordsDirectory)
|
||||
for _ in 0..<30 {
|
||||
if await store.countEvictedFiles(inSubdirectory: Self.recordsDirectory) == 0 { break }
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
}
|
||||
|
||||
do {
|
||||
try cacheContext.delete(model: NoteEntity.self)
|
||||
try cacheContext.save()
|
||||
} catch {
|
||||
report("Cache rebuild failed", error)
|
||||
return
|
||||
}
|
||||
|
||||
let stubIDs = await tombstones.listStubIDs()
|
||||
for path in await listRecordPaths(in: store) {
|
||||
if stubIDs.contains(DocumentFileStore.documentID(fromRelativePath: path)) {
|
||||
try? await store.remove(at: Self.dataPath(path))
|
||||
continue
|
||||
}
|
||||
await importRecord(at: path, using: store)
|
||||
}
|
||||
lastSyncError = nil
|
||||
}
|
||||
|
||||
/// Records-relative paths of all live record files (placeholder-aware —
|
||||
/// includes evicted iCloud-only files).
|
||||
private func listRecordPaths(in store: DocumentFileStore) async -> [String] {
|
||||
let prefix = Self.recordsDirectory + "/"
|
||||
return await store.list(inSubdirectory: Self.recordsDirectory)
|
||||
.map { String($0.dropFirst(prefix.count)) }
|
||||
.sorted()
|
||||
}
|
||||
|
||||
// MARK: - Maintenance
|
||||
|
||||
private func performLaunchMaintenance() {
|
||||
Task(priority: .utility) { [tombstones] in
|
||||
_ = try? await tombstones?.prune()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schema migration
|
||||
|
||||
/// Read-time upgrade for documents behind the current schema. A no-op
|
||||
/// while `currentSchemaVersion` is 1, but wired into both read paths so
|
||||
/// the first schema bump migrates instead of silently skipping. Write-back
|
||||
/// happens lazily on the next save — never from live monitor events.
|
||||
private func migrateIfNeeded(_ note: Note) -> Note {
|
||||
guard note.meta.schemaVersion < Note.currentSchemaVersion else { return note }
|
||||
var migrated = note
|
||||
migrated.meta.schemaVersion = Note.currentSchemaVersion
|
||||
return migrated
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||