Files
notes/Notes/Services/ContextService.swift
rzen 9b231a1978 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.
2026-07-14 20:30:46 -04:00

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
}
}