App/Sources/Intents/AnswerIntents.swift
172 lines · 8030 bytes
19 symbols in this file
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}