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.
97 lines
3.4 KiB
Swift
97 lines
3.4 KiB
Swift
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
|
|
}
|
|
}
|