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/Widgets/MoonriseWidgets.swift

b327e0d6e7ab67511c5bade3f3c3a93834f6e9ca
moonrise/App/Widgets/MoonriseWidgets.swift history · blame · raw

233 lines · 8401 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        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}