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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user