M1: moon and planets widgets !5

merged merged by cmc on 2026-10-18 01:54 UTC · krz/moonrise:m1-widgets into main

5 files changed, +293 −9

Layout: unified · split

App/Widgets/Info.plist added +31
@@ -0,0 +1,31 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>CFBundleDevelopmentRegion</key>
6 <string>$(DEVELOPMENT_LANGUAGE)</string>
7 <key>CFBundleDisplayName</key>
8 <string>Moonrise</string>
9 <key>CFBundleExecutable</key>
10 <string>$(EXECUTABLE_NAME)</string>
11 <key>CFBundleIdentifier</key>
12 <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13 <key>CFBundleInfoDictionaryVersion</key>
14 <string>6.0</string>
15 <key>CFBundleName</key>
16 <string>$(PRODUCT_NAME)</string>
17 <key>CFBundlePackageType</key>
18 <string>XPC!</string>
19 <key>CFBundleShortVersionString</key>
20 <string>1.0</string>
21 <key>CFBundleVersion</key>
22 <string>1</string>
23 <key>NSExtension</key>
24 <dict>
25 <key>NSExtensionPointIdentifier</key>
26 <string>com.apple.widgetkit-extension</string>
27 </dict>
28 <key>NSWidgetWantsLocation</key>
29 <true/>
30</dict>
31</plist>
App/Widgets/MoonriseWidgets.swift added +194
@@ -0,0 +1,194 @@
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 }
13}
14
15/// Last known location, or Chicago. The widget does not request a fresh fix.
16func widgetObserver() -> (Observer, Bool) {
17 if let c = CLLocationManager().location?.coordinate {
18 return (Observer(latitude: c.latitude, longitude: c.longitude), true)
19 }
20 return (Observer(latitude: 41.8781, longitude: -87.6298), false)
21}
22
23/// Astronomy Engine is not thread-safe and WidgetKit may build timelines concurrently.
24let engineLock = NSLock()
25
26/// Hourly entries for the next 12 hours.
27func hourly(from date: Date) -> [Date] {
28 (0..<12).map { date.addingTimeInterval(Double($0) * 3600) }
29}
30
31// MARK: - Moon
32
33struct MoonEntry: TimelineEntry {
34 var date: Date
35 var illumination: Double
36 var waxing: Bool
37 var nextQuarter: (SkyEngine.MoonQuarter, Date)?
38}
39
40struct MoonProvider: TimelineProvider {
41 func placeholder(in context: Context) -> MoonEntry {
42 MoonEntry(date: .now, illumination: 0.44, waxing: true, nextQuarter: nil)
43 }
44
45 func getSnapshot(in context: Context, completion: @escaping (MoonEntry) -> Void) {
46 completion(entry(at: .now))
47 }
48
49 func getTimeline(in context: Context, completion: @escaping (Timeline<MoonEntry>) -> Void) {
50 completion(Timeline(entries: hourly(from: .now).map(entry), policy: .atEnd))
51 }
52
53 func entry(at date: Date) -> MoonEntry {
54 engineLock.lock()
55 defer { engineLock.unlock() }
56 let illumination = (try? SkyEngine.illumination(.moon, at: date).phaseFraction) ?? 0
57 let phase = (try? SkyEngine.moonPhase(at: date)) ?? 0
58 let q = try? SkyEngine.nextMoonQuarter(after: date)
59 return MoonEntry(date: date, illumination: illumination, waxing: phase < 180, nextQuarter: q.map { ($0.quarter, $0.date) })
60 }
61}
62
63func quarterName(_ q: SkyEngine.MoonQuarter) -> String {
64 switch q {
65 case .new: "New moon"
66 case .firstQuarter: "First quarter"
67 case .full: "Full moon"
68 case .lastQuarter: "Last quarter"
69 }
70}
71
72struct MoonWidgetView: View {
73 @Environment(\.widgetFamily) var family
74 var entry: MoonEntry
75
76 var percent: String { "\(Int((entry.illumination * 100).rounded()))%" }
77
78 var body: some View {
79 switch family {
80 case .accessoryCircular:
81 ZStack {
82 AccessoryWidgetBackground()
83 MoonDisc(fraction: entry.illumination, waxing: entry.waxing, light: .white, dark: .white.opacity(0.15))
84 .padding(6)
85 }
86 case .accessoryInline:
87 Text("Moon \(percent) \(entry.waxing ? "waxing" : "waning")")
88 default:
89 VStack(alignment: .leading, spacing: 6) {
90 MoonDisc(fraction: entry.illumination, waxing: entry.waxing)
91 .frame(maxWidth: .infinity, alignment: .leading)
92 .frame(height: 64)
93 Spacer(minLength: 0)
94 Text("Moon \(percent)").font(.system(size: 15, weight: .medium))
95 if let (q, d) = entry.nextQuarter {
96 Text("\(quarterName(q)) \(d.formatted(.dateTime.weekday(.abbreviated).hour().minute()))")
97 .font(.system(size: 11)).opacity(0.7)
98 }
99 }
100 .foregroundStyle(Color(red: 0.95, green: 0.95, blue: 0.93))
101 }
102 }
103}
104
105struct MoonWidget: Widget {
106 var body: some WidgetConfiguration {
107 StaticConfiguration(kind: "moon", provider: MoonProvider()) { entry in
108 MoonWidgetView(entry: entry)
109 .containerBackground(Color(red: 0.043, green: 0.078, blue: 0.141), for: .widget)
110 }
111 .configurationDisplayName("Moon")
112 .description("Tonight's moon phase.")
113 .supportedFamilies([.systemSmall, .accessoryCircular, .accessoryInline])
114 }
115}
116
117// MARK: - Planets
118
119struct PlanetsEntry: TimelineEntry {
120 struct Row: Hashable {
121 var name: String
122 var detail: String
123 }
124
125 var date: Date
126 var rows: [Row]
127 var located: Bool
128}
129
130struct PlanetsProvider: TimelineProvider {
131 func placeholder(in context: Context) -> PlanetsEntry {
132 PlanetsEntry(date: .now, rows: [.init(name: "Saturn", detail: "up now, 30° SE")], located: true)
133 }
134
135 func getSnapshot(in context: Context, completion: @escaping (PlanetsEntry) -> Void) {
136 completion(entry(at: .now))
137 }
138
139 func getTimeline(in context: Context, completion: @escaping (Timeline<PlanetsEntry>) -> Void) {
140 completion(Timeline(entries: hourly(from: .now).map(entry), policy: .atEnd))
141 }
142
143 func entry(at date: Date) -> PlanetsEntry {
144 let (observer, located) = widgetObserver()
145 engineLock.lock()
146 defer { engineLock.unlock() }
147 guard let t = try? Tonight(at: date, from: observer) else { return PlanetsEntry(date: date, rows: [], located: located) }
148 let time: (Date) -> String = { $0.formatted(date: .omitted, time: .shortened) }
149 let rows = t.planets.filter { $0.best != nil }.map { p -> PlanetsEntry.Row in
150 let name = p.body.rawValue.capitalized
151 if p.now.altitude > 0 { return .init(name: name, detail: String(format: "up, %.0f°", p.now.altitude)) }
152 if let rise = p.rise, rise < t.end { return .init(name: name, detail: "rises \(time(rise))") }
153 return .init(name: name, detail: "tonight")
154 }
155 return PlanetsEntry(date: date, rows: rows, located: located)
156 }
157}
158
159struct PlanetsWidgetView: View {
160 var entry: PlanetsEntry
161
162 var body: some View {
163 VStack(alignment: .leading, spacing: 4) {
164 Text("Planets tonight").font(.system(size: 11, weight: .medium)).textCase(.uppercase).opacity(0.6)
165 if entry.rows.isEmpty {
166 Text("None well placed").font(.system(size: 13))
167 }
168 ForEach(entry.rows, id: \.self) { row in
169 HStack {
170 Text(row.name).font(.system(size: 14, weight: .medium))
171 Spacer()
172 Text(row.detail).font(.system(size: 12).monospacedDigit()).opacity(0.75)
173 }
174 }
175 Spacer(minLength: 0)
176 if !entry.located {
177 Text("Open Moonrise to set location").font(.system(size: 10)).opacity(0.5)
178 }
179 }
180 .foregroundStyle(Color(red: 0.95, green: 0.95, blue: 0.93))
181 }
182}
183
184struct PlanetsWidget: Widget {
185 var body: some WidgetConfiguration {
186 StaticConfiguration(kind: "planets", provider: PlanetsProvider()) { entry in
187 PlanetsWidgetView(entry: entry)
188 .containerBackground(Color(red: 0.043, green: 0.078, blue: 0.141), for: .widget)
189 }
190 .configurationDisplayName("Planets tonight")
191 .description("Which planets are up tonight and when they rise.")
192 .supportedFamilies([.systemSmall, .systemMedium])
193 }
194}
App/project.yml +28
@@ -18,6 +18,7 @@ targets:
1818 product: SkyRenderer
1919 - package: MoonriseKit
2020 product: Feeds
21 - target: MoonriseWidgets
2122 entitlements:
2223 path: Sources/Moonrise.entitlements
2324 properties:
@@ -37,3 +38,30 @@ targets:
3738 INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleLightContent
3839 INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait
3940 INFOPLIST_KEY_NSLocationWhenInUseUsageDescription: Moonrise uses your location to draw the sky above you.
41 MoonriseWidgets:
42 type: app-extension
43 platform: iOS
44 sources: [Widgets]
45 dependencies:
46 - package: MoonriseKit
47 product: SkyEngine
48 - package: MoonriseKit
49 product: SkyRenderer
50 - sdk: WidgetKit.framework
51 - sdk: SwiftUI.framework
52 info:
53 path: Widgets/Info.plist
54 properties:
55 CFBundleDisplayName: Moonrise
56 NSWidgetWantsLocation: true
57 NSExtension:
58 NSExtensionPointIdentifier: com.apple.widgetkit-extension
59 settings:
60 base:
61 PRODUCT_BUNDLE_IDENTIFIER: sh.krz.moonrise.widgets
62 DEVELOPMENT_TEAM: ZCNAX3VL9D
63 CODE_SIGN_STYLE: Automatic
64 SWIFT_VERSION: "6.0"
65 TARGETED_DEVICE_FAMILY: "1"
66 MARKETING_VERSION: "0.1"
67 CURRENT_PROJECT_VERSION: "1"
Packages/MoonriseKit/Sources/SkyRenderer/MoonDisc.swift added +27
@@ -0,0 +1,27 @@
1import SwiftUI
2
3/// The moon at a given phase, filling its frame.
4public struct MoonDisc: View {
5 public var fraction: Double
6 public var waxing: Bool
7 public var light: Color
8 public var dark: Color
9
10 public init(fraction: Double, waxing: Bool, light: Color = SkyStyle().moonLight, dark: Color = SkyStyle().moonDark) {
11 self.fraction = fraction
12 self.waxing = waxing
13 self.light = light
14 self.dark = dark
15 }
16
17 public var body: some View {
18 Canvas { context, size in
19 let r = min(size.width, size.height) / 2
20 let c = CGPoint(x: size.width / 2, y: size.height / 2)
21 context.fill(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(dark))
22 context.fill(Path.moonLit(center: c, radius: r, fraction: fraction, waxing: waxing), with: .color(light))
23 }
24 .aspectRatio(1, contentMode: .fit)
25 .accessibilityLabel("Moon \(Int((fraction * 100).rounded())) percent lit, \(waxing ? "waxing" : "waning")")
26 }
27}
Packages/MoonriseKit/Sources/SkyRenderer/SkyView.swift +13 −9
@@ -147,15 +147,7 @@ public struct SkyView: View {
147147 func drawMoon(_ context: inout GraphicsContext, at c: CGPoint, radius r: Double, fraction: Double, waxing: Bool) {
148148 let disc = CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)
149149 context.fill(Path(ellipseIn: disc), with: .color(style.moonDark))
150 // Lit limb: a half disc plus or minus the terminator ellipse.
151 let side: Double = waxing ? 1 : -1
152 let e = r * abs(1 - 2 * fraction)
153 var lit = Path()
154 lit.move(to: CGPoint(x: c.x, y: c.y - r))
155 lit.addArc(center: c, radius: r, startAngle: .degrees(-90), endAngle: .degrees(90), clockwise: side < 0)
156 lit.addEllipseArc(center: c, rx: e, ry: r, gibbous: fraction > 0.5, side: side)
157 lit.closeSubpath()
158 context.fill(lit, with: .color(style.moonLight))
150 context.fill(Path.moonLit(center: c, radius: r, fraction: fraction, waxing: waxing), with: .color(style.moonLight))
159151 }
160152
161153 func drawHorizon(_ context: inout GraphicsContext, _ p: Projection, size: CGSize) {
@@ -186,6 +178,18 @@ public struct SkyView: View {
186178}
187179
188180extension Path {
181 /// The lit part of the moon's disc: a half disc plus or minus the terminator ellipse.
182 /// Waxing is lit on the right, as seen from the northern hemisphere.
183 public static func moonLit(center c: CGPoint, radius r: Double, fraction: Double, waxing: Bool) -> Path {
184 let side: Double = waxing ? 1 : -1
185 var lit = Path()
186 lit.move(to: CGPoint(x: c.x, y: c.y - r))
187 lit.addArc(center: c, radius: r, startAngle: .degrees(-90), endAngle: .degrees(90), clockwise: side < 0)
188 lit.addEllipseArc(center: c, rx: r * abs(1 - 2 * fraction), ry: r, gibbous: fraction > 0.5, side: side)
189 lit.closeSubpath()
190 return lit
191 }
192
189193 /// Half of the terminator ellipse, from the moon's bottom to its top.
190194 mutating func addEllipseArc(center c: CGPoint, rx: Double, ry: Double, gibbous: Bool, side: Double) {
191195 // Gibbous: terminator bulges into the dark half; crescent: into the lit half.