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