App/Widgets/MoonriseWidgets.swift
232 lines · 8371 bytes
1import CoreLocation
2import SkyEngine
3import SkyRenderer
4import SwiftUI
5import WidgetKit
6
7@main
8struct MoonriseWidgets: WidgetBundle {
9 var body: some Widget {
10 MoonWidget()
11 PlanetsWidget()
12 #if os(iOS)
13 LaunchLiveActivity()
14 EventLiveActivity()
15 PassLiveActivity()
16 #endif
17 }
18}
19
20/// Last known location, or Chicago. The widget does not request a fresh fix.
21func widgetObserver() -> (Observer, Bool) {
22 if let c = CLLocationManager().location?.coordinate {
23 return (Observer(latitude: c.latitude, longitude: c.longitude), true)
24 }
25 return (Observer(latitude: 41.8781, longitude: -87.6298), false)
26}
27
28/// Astronomy Engine is not thread-safe and WidgetKit may build timelines concurrently.
29let engineLock = NSLock()
30
31/// Hourly entries for the next 12 hours.
32func hourly(from date: Date) -> [Date] {
33 (0..<12).map { date.addingTimeInterval(Double($0) * 3600) }
34}
35
36// MARK: - Moon
37
38struct MoonEntry: TimelineEntry {
39 var date: Date
40 var illumination: Double
41 var waxing: Bool
42 var nextQuarter: (SkyEngine.MoonQuarter, Date)?
43}
44
45struct MoonProvider: TimelineProvider {
46 func placeholder(in context: Context) -> MoonEntry {
47 MoonEntry(date: .now, illumination: 0.44, waxing: true, nextQuarter: nil)
48 }
49
50 func getSnapshot(in context: Context, completion: @escaping (MoonEntry) -> Void) {
51 completion(entry(at: .now))
52 }
53
54 func getTimeline(in context: Context, completion: @escaping (Timeline<MoonEntry>) -> Void) {
55 completion(Timeline(entries: hourly(from: .now).map(entry), policy: .atEnd))
56 }
57
58 func entry(at date: Date) -> MoonEntry {
59 engineLock.lock()
60 defer { engineLock.unlock() }
61 let illumination = (try? SkyEngine.illumination(.moon, at: date).phaseFraction) ?? 0
62 let phase = (try? SkyEngine.moonPhase(at: date)) ?? 0
63 let q = try? SkyEngine.nextMoonQuarter(after: date)
64 return MoonEntry(date: date, illumination: illumination, waxing: phase < 180, nextQuarter: q.map { ($0.quarter, $0.date) })
65 }
66}
67
68func quarterName(_ q: SkyEngine.MoonQuarter) -> String {
69 switch q {
70 case .new: "New moon"
71 case .firstQuarter: "First quarter"
72 case .full: "Full moon"
73 case .lastQuarter: "Last quarter"
74 }
75}
76
77struct MoonWidgetView: View {
78 @Environment(\.widgetFamily) var family
79 var entry: MoonEntry
80
81 var percent: String { "\(Int((entry.illumination * 100).rounded()))%" }
82
83 var body: some View {
84 switch family {
85 case .accessoryCircular:
86 ZStack {
87 AccessoryWidgetBackground()
88 MoonDisc(fraction: entry.illumination, waxing: entry.waxing, light: .white, dark: .white.opacity(0.15))
89 .padding(6)
90 }
91 case .accessoryInline:
92 Text("Moon \(percent) \(entry.waxing ? "waxing" : "waning")")
93 #if os(watchOS)
94 case .accessoryCorner:
95 MoonDisc(fraction: entry.illumination, waxing: entry.waxing, light: .white, dark: .white.opacity(0.15))
96 .padding(4)
97 .widgetLabel(percent)
98 #endif
99 default:
100 VStack(alignment: .leading, spacing: 6) {
101 MoonDisc(fraction: entry.illumination, waxing: entry.waxing)
102 .frame(maxWidth: .infinity, alignment: .leading)
103 .frame(height: 64)
104 Spacer(minLength: 0)
105 Text("Moon \(percent)").font(.system(size: 15, weight: .medium))
106 if let (q, d) = entry.nextQuarter {
107 Text("\(quarterName(q)) \(d.formatted(.dateTime.weekday(.abbreviated).hour().minute()))")
108 .font(.system(size: 11)).opacity(0.7)
109 }
110 }
111 .foregroundStyle(Color(red: 0.95, green: 0.95, blue: 0.93))
112 }
113 }
114}
115
116struct MoonWidget: Widget {
117 var body: some WidgetConfiguration {
118 StaticConfiguration(kind: "moon", provider: MoonProvider()) { entry in
119 MoonWidgetView(entry: entry)
120 .containerBackground(Color(red: 0.043, green: 0.078, blue: 0.141), for: .widget)
121 }
122 .configurationDisplayName("Moon")
123 .description("Tonight's moon phase.")
124 #if os(watchOS)
125 .supportedFamilies([.accessoryCircular, .accessoryInline, .accessoryCorner])
126 #else
127 .supportedFamilies([.systemSmall, .accessoryCircular, .accessoryInline])
128 #endif
129 }
130}
131
132// MARK: - Planets
133
134struct PlanetsEntry: TimelineEntry {
135 struct Row: Hashable {
136 var name: String
137 var detail: String
138 }
139
140 var date: Date
141 var rows: [Row]
142 var located: Bool
143}
144
145struct PlanetsProvider: TimelineProvider {
146 func placeholder(in context: Context) -> PlanetsEntry {
147 PlanetsEntry(date: .now, rows: [.init(name: "Saturn", detail: "up now, 30° SE")], located: true)
148 }
149
150 func getSnapshot(in context: Context, completion: @escaping (PlanetsEntry) -> Void) {
151 completion(entry(at: .now))
152 }
153
154 func getTimeline(in context: Context, completion: @escaping (Timeline<PlanetsEntry>) -> Void) {
155 completion(Timeline(entries: hourly(from: .now).map(entry), policy: .atEnd))
156 }
157
158 func entry(at date: Date) -> PlanetsEntry {
159 let (observer, located) = widgetObserver()
160 engineLock.lock()
161 defer { engineLock.unlock() }
162 guard let t = try? Tonight(at: date, from: observer) else { return PlanetsEntry(date: date, rows: [], located: located) }
163 let time: (Date) -> String = { $0.formatted(date: .omitted, time: .shortened) }
164 let rows = t.planets.filter { $0.best != nil }.map { p -> PlanetsEntry.Row in
165 let name = p.body.rawValue.capitalized
166 if p.now.altitude > 0 { return .init(name: name, detail: String(format: "up, %.0f°", p.now.altitude)) }
167 if let rise = p.rise, rise < t.end { return .init(name: name, detail: "rises \(time(rise))") }
168 return .init(name: name, detail: "tonight")
169 }
170 return PlanetsEntry(date: date, rows: rows, located: located)
171 }
172}
173
174struct PlanetsWidgetView: View {
175 @Environment(\.widgetFamily) var family
176 var entry: PlanetsEntry
177
178 var body: some View {
179 if family == .accessoryRectangular { compact } else { full }
180 }
181
182 /// Watch complication and Lock Screen: up to three planets, no header.
183 private var compact: some View {
184 VStack(alignment: .leading, spacing: 1) {
185 if entry.rows.isEmpty { Text("No planets tonight").font(.system(size: 13)) }
186 ForEach(entry.rows.prefix(3), id: \.self) { row in
187 HStack {
188 Text(row.name).font(.system(size: 13, weight: .medium))
189 Spacer()
190 Text(row.detail).font(.system(size: 12).monospacedDigit())
191 }
192 }
193 }
194 }
195
196 private var full: some View {
197 VStack(alignment: .leading, spacing: 4) {
198 Text("Planets tonight").font(.system(size: 11, weight: .medium)).textCase(.uppercase).opacity(0.6)
199 if entry.rows.isEmpty {
200 Text("None well placed").font(.system(size: 13))
201 }
202 ForEach(entry.rows, id: \.self) { row in
203 HStack {
204 Text(row.name).font(.system(size: 14, weight: .medium))
205 Spacer()
206 Text(row.detail).font(.system(size: 12).monospacedDigit()).opacity(0.75)
207 }
208 }
209 Spacer(minLength: 0)
210 if !entry.located {
211 Text("Open Moonrise to set location").font(.system(size: 10)).opacity(0.5)
212 }
213 }
214 .foregroundStyle(Color(red: 0.95, green: 0.95, blue: 0.93))
215 }
216}
217
218struct PlanetsWidget: Widget {
219 var body: some WidgetConfiguration {
220 StaticConfiguration(kind: "planets", provider: PlanetsProvider()) { entry in
221 PlanetsWidgetView(entry: entry)
222 .containerBackground(Color(red: 0.043, green: 0.078, blue: 0.141), for: .widget)
223 }
224 .configurationDisplayName("Planets tonight")
225 .description("Which planets are up tonight and when they rise.")
226 #if os(watchOS)
227 .supportedFamilies([.accessoryRectangular])
228 #else
229 .supportedFamilies([.systemSmall, .systemMedium])
230 #endif
231 }
232}