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

v1.0.0
moonrise/App/Widgets/MoonriseWidgets.swift history · blame · raw

231 lines · 8344 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        #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}