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

623ba8919c3beb07ea7cc2bd76bb989ab0e93c25
moonrise/App/Widgets/MoonriseWidgets.swift history · blame · raw

232 lines · 8374 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        #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}