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