krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

App/Sources/Intents/AnswerIntents.swift

6c4b28384d65e09486e0ad62f2ac9d10401cc80c
moonrise/App/Sources/Intents/AnswerIntents.swift history · blame · raw

172 lines · 8030 bytes

  1import Answers
  2import AppIntents
  3import Feeds
  4import Foundation
  5import Library
  6import SkyEngine
  7
  8extension Place {
  9    /// The Sky tab's last observer, else the city chosen in Settings, else Chicago.
 10    static var current: Place {
 11        let d = UserDefaults.standard
 12        if let saved = Place.saved(in: d) { return saved }
 13        if d.string(forKey: SettingsKey.locationMode) == "city", let name = d.string(forKey: SettingsKey.cityName), d.object(forKey: SettingsKey.cityLatitude) != nil {
 14            return Place(latitude: d.double(forKey: SettingsKey.cityLatitude), longitude: d.double(forKey: SettingsKey.cityLongitude), name: name, kind: .city)
 15        }
 16        return .fallback
 17    }
 18}
 19
 20enum AnswerError: Error, CustomLocalizedStringResourceConvertible {
 21    case offline
 22
 23    var localizedStringResource: LocalizedStringResource {
 24        "Moonrise couldn't reach the network. Try again when you're online."
 25    }
 26}
 27
 28/// The whole answer for AirPods and HomePod; its first sentence alongside the result view.
 29private func dialog(_ spoken: String) -> IntentDialog {
 30    let first = spoken.components(separatedBy: ". ").first.map { $0.hasSuffix(".") ? $0 : $0 + "." } ?? spoken
 31    return IntentDialog(full: "\(spoken)", supporting: "\(first)")
 32}
 33
 34struct MoonPhaseIntent: AppIntent {
 35    static let title: LocalizedStringResource = "Moon Phase"
 36    static let description = IntentDescription("The Moon's phase, how much of it is lit, and the next quarter.")
 37    static let supportedModes: IntentModes = .background
 38
 39    func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
 40        let a = try MoonAnswer(at: .now)
 41        return .result(dialog: dialog(a.spoken), view: AnswerSnippet(heading: "Moon", rows: a.rows, moon: (a.illumination, a.waxing)))
 42    }
 43}
 44
 45struct TonightIntent: AppIntent {
 46    static let title: LocalizedStringResource = "Tonight's Sky"
 47    static let description = IntentDescription("The bright planets well placed tonight, with sunset and sunrise.")
 48    static let supportedModes: IntentModes = .background
 49
 50    func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
 51        let a = try TonightAnswer(at: .now, place: .current)
 52        return .result(dialog: dialog(a.spoken), view: AnswerSnippet(heading: a.heading, rows: a.rows))
 53    }
 54}
 55
 56enum SatelliteOption: String, AppEnum {
 57    case iss, tiangong, hubble
 58
 59    static let typeDisplayRepresentation: TypeDisplayRepresentation = "Satellite"
 60    static let caseDisplayRepresentations: [SatelliteOption: DisplayRepresentation] = [
 61        .iss: DisplayRepresentation(title: "ISS", synonyms: ["the ISS", "the space station", "the International Space Station"]),
 62        .tiangong: DisplayRepresentation(title: "Tiangong", synonyms: ["the Chinese space station"]),
 63        .hubble: DisplayRepresentation(title: "Hubble", synonyms: ["the Hubble Space Telescope", "Hubble telescope"]),
 64    ]
 65
 66    var satellite: Satellite {
 67        switch self {
 68        case .iss: .iss
 69        case .tiangong: .tiangong
 70        case .hubble: .hubble
 71        }
 72    }
 73}
 74
 75struct NextPassIntent: AppIntent {
 76    static let title: LocalizedStringResource = "Next Satellite Pass"
 77    static let description = IntentDescription("When the ISS, Tiangong or Hubble next crosses your sky, preferring a pass you can see.")
 78    static let supportedModes: IntentModes = .background
 79
 80    @Parameter(title: "Satellite", default: .iss)
 81    var satellite: SatelliteOption
 82
 83    private struct SatelliteList: Decodable { var satellites: [OrbitalElements] }
 84
 85    func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
 86        let s = satellite.satellite
 87        guard let apiBase,
 88              let (data, _) = try? await URLSession.shared.data(from: apiBase.appending(path: "v1/satellites")),
 89              let list = try? JSONDecoder().decode(SatelliteList.self, from: data) else { throw AnswerError.offline }
 90        let place = Place.current
 91        let passes = list.satellites.first { $0.NORAD_CAT_ID == s.rawValue }
 92            .map { SkyEngine.passes(of: $0, from: .now, hours: 72, observer: place.observer) } ?? []
 93        let a = PassAnswer(satellite: s, passes: passes, at: .now, place: place)
 94        return .result(dialog: dialog(a.spoken), view: AnswerSnippet(heading: "\(s.name) · \(place.label)", rows: a.rows))
 95    }
 96}
 97
 98struct NextLaunchIntent: AppIntent {
 99    static let title: LocalizedStringResource = "Next Launch"
100    static let description = IntentDescription("The next rocket launch: rocket, mission, launch site and countdown.")
101    static let supportedModes: IntentModes = .background
102
103    func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
104        guard let apiBase, let list = try? await LaunchFeed.fetch(base: apiBase) else { throw AnswerError.offline }
105        let a = LaunchAnswer(launches: list.launches, at: .now)
106        return .result(dialog: dialog(a.spoken), view: AnswerSnippet(heading: "Next launch", rows: a.rows))
107    }
108}
109
110struct SpaceWeatherIntent: AppIntent {
111    static let title: LocalizedStringResource = "Aurora and Kp"
112    static let description = IntentDescription("The planetary Kp index now and over the next day, and whether aurora may be seen from your location.")
113    static let supportedModes: IntentModes = .background
114
115    func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
116        guard let weather = try? await SpaceWeather.fetch() else { throw AnswerError.offline }
117        let place = Place.current
118        let a = AuroraAnswer(weather: weather, place: place)
119        return .result(dialog: dialog(a.spoken), view: AnswerSnippet(heading: "Space weather · \(place.label)", rows: a.rows, footnote: a.footnote))
120    }
121}
122
123enum ProbeOption: String, AppEnum {
124    case voyager1, voyager2, pioneer10, pioneer11, newHorizons
125
126    static let typeDisplayRepresentation: TypeDisplayRepresentation = "Probe"
127    static let caseDisplayRepresentations: [ProbeOption: DisplayRepresentation] = [
128        .voyager1: DisplayRepresentation(title: "Voyager 1", synonyms: ["Voyager", "Voyager one"]),
129        .voyager2: DisplayRepresentation(title: "Voyager 2", synonyms: ["Voyager two"]),
130        .pioneer10: DisplayRepresentation(title: "Pioneer 10", synonyms: ["Pioneer ten", "Pioneer"]),
131        .pioneer11: DisplayRepresentation(title: "Pioneer 11", synonyms: ["Pioneer eleven"]),
132        .newHorizons: DisplayRepresentation(title: "New Horizons"),
133    ]
134
135    var probe: Probe {
136        switch self {
137        case .voyager1: .voyager1
138        case .voyager2: .voyager2
139        case .pioneer10: .pioneer10
140        case .pioneer11: .pioneer11
141        case .newHorizons: .newHorizons
142        }
143    }
144}
145
146struct ProbeDistanceIntent: AppIntent {
147    static let title: LocalizedStringResource = "Probe Distance"
148    static let description = IntentDescription("How far a deep-space probe is from Earth, and how long its signal takes to reach us.")
149    static let supportedModes: IntentModes = .background
150
151    @Parameter(title: "Probe", default: .voyager1)
152    var probe: ProbeOption
153
154    func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
155        let a = try ProbeAnswer(probe: probe.probe, at: .now)
156        return .result(dialog: dialog(a.spoken), view: AnswerSnippet(heading: a.probe.rawValue, rows: a.rows))
157    }
158}
159
160struct LookUpIntent: AppIntent {
161    static let title: LocalizedStringResource = "Look Up"
162    static let description = IntentDescription("A Library page's description and key facts.")
163    static let supportedModes: IntentModes = .background
164
165    @Parameter(title: "Object", requestValueDialog: "Which object?")
166    var page: LibraryPageEntity
167
168    func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
169        let a = LookUpAnswer(page: page.page)
170        return .result(dialog: dialog(a.spoken), view: AnswerSnippet(heading: page.page.title, lead: a.subtitle, rows: a.rows))
171    }
172}