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

Commit 2f2951ff01

2f2951ff019ca524496a2196fc47d10c44bbac99

parent: 23365270e4

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-18 21:39 UTC

Add an Apple Watch app and complications; build for Mac as Designed for iPad

Layout: unified · split

App/Watch/Assets.xcassets/AppIcon.appiconset/Contents.json added +6
@@ -0,0 +1,6 @@
1{
2 "images" : [
3 { "filename" : "icon-1024.png", "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024" }
4 ],
5 "info" : { "author" : "xcode", "version" : 1 }
6}
App/Watch/Assets.xcassets/AppIcon.appiconset/icon-1024.png added

Binary file not shown.

App/Watch/Assets.xcassets/Contents.json added +3
@@ -0,0 +1,3 @@
1{
2 "info" : { "author" : "xcode", "version" : 1 }
3}
App/Watch/MoonriseWatchApp.swift added +96
@@ -0,0 +1,96 @@
1import CoreLocation
2import SkyEngine
3import SkyRenderer
4import SwiftUI
5
6@main
7struct MoonriseWatchApp: App {
8 var body: some Scene {
9 WindowGroup { TonightView() }
10 }
11}
12
13/// Moon phase, sunset and sunrise, and the planets up tonight.
14struct TonightView: View {
15 @State private var location = WatchLocation()
16 @State private var tonight: Tonight?
17
18 var body: some View {
19 ScrollView {
20 VStack(alignment: .leading, spacing: 10) {
21 if let t = tonight {
22 HStack(spacing: 10) {
23 MoonDisc(fraction: t.moonIllumination, waxing: t.moonPhase < 180)
24 .frame(width: 44, height: 44)
25 .accessibilityHidden(true)
26 VStack(alignment: .leading) {
27 Text(DailySummary.phaseName(t.moonPhase)).font(.headline)
28 Text("\(Int((t.moonIllumination * 100).rounded()))% lit").font(.footnote).foregroundStyle(.secondary)
29 }
30 }
31 .accessibilityElement(children: .combine)
32 if let s = t.sunset { row("Sunset", time(s)) }
33 if let r = t.sunrise { row("Sunrise", time(r)) }
34 Text("Planets").font(.footnote.weight(.medium)).foregroundStyle(.secondary).padding(.top, 4)
35 let up = t.planets.filter { $0.best != nil }
36 if up.isEmpty { Text("None well placed tonight").font(.footnote) }
37 ForEach(up, id: \.body) { p in
38 row(p.body.rawValue.capitalized, p.best.map { "best \(time($0.date))" } ?? "")
39 }
40 if !location.located {
41 Text("Using Chicago until location is allowed.").font(.caption2).foregroundStyle(.secondary)
42 }
43 } else {
44 ProgressView()
45 }
46 }
47 .frame(maxWidth: .infinity, alignment: .leading)
48 }
49 .navigationTitle("Tonight")
50 .task(id: location.observer) {
51 let observer = location.observer
52 tonight = await Task.detached { try? Tonight(at: .now, from: observer) }.value
53 }
54 .onAppear { location.start() }
55 }
56
57 private func row(_ label: String, _ value: String) -> some View {
58 HStack {
59 Text(label)
60 Spacer()
61 Text(value).monospacedDigit().foregroundStyle(.secondary)
62 }
63 .font(.footnote)
64 }
65
66 private func time(_ d: Date) -> String { d.formatted(date: .omitted, time: .shortened) }
67}
68
69@MainActor
70@Observable
71final class WatchLocation: NSObject, CLLocationManagerDelegate {
72 private(set) var observer = Observer(latitude: 41.8781, longitude: -87.6298)
73 private(set) var located = false
74 private let manager = CLLocationManager()
75
76 func start() {
77 manager.delegate = self
78 manager.desiredAccuracy = kCLLocationAccuracyKilometer
79 manager.requestWhenInUseAuthorization()
80 manager.requestLocation()
81 }
82
83 nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
84 guard let c = locations.last?.coordinate else { return }
85 Task { @MainActor in
86 observer = Observer(latitude: c.latitude, longitude: c.longitude)
87 located = true
88 }
89 }
90
91 nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {}
92
93 nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
94 Task { @MainActor in self.manager.requestLocation() }
95 }
96}
App/Watch/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 +35
@@ -9,7 +9,9 @@ struct MoonriseWidgets: WidgetBundle {
9 var body: some Widget { 9 var body: some Widget {
10 MoonWidget() 10 MoonWidget()
11 PlanetsWidget() 11 PlanetsWidget()
12 #if os(iOS)
12 LaunchLiveActivity() 13 LaunchLiveActivity()
14 #endif
13 } 15 }
14} 16}
15 17
@@ -86,6 +88,12 @@ struct MoonWidgetView: View {
86 } 88 }
87 case .accessoryInline: 89 case .accessoryInline:
88 Text("Moon \(percent) \(entry.waxing ? "waxing" : "waning")") 90 Text("Moon \(percent) \(entry.waxing ? "waxing" : "waning")")
91 #if os(watchOS)
92 case .accessoryCorner:
93 MoonDisc(fraction: entry.illumination, waxing: entry.waxing, light: .white, dark: .white.opacity(0.15))
94 .padding(4)
95 .widgetLabel(percent)
96 #endif
89 default: 97 default:
90 VStack(alignment: .leading, spacing: 6) { 98 VStack(alignment: .leading, spacing: 6) {
91 MoonDisc(fraction: entry.illumination, waxing: entry.waxing) 99 MoonDisc(fraction: entry.illumination, waxing: entry.waxing)
@@ -111,7 +119,11 @@ struct MoonWidget: Widget {
111 } 119 }
112 .configurationDisplayName("Moon") 120 .configurationDisplayName("Moon")
113 .description("Tonight's moon phase.") 121 .description("Tonight's moon phase.")
122 #if os(watchOS)
123 .supportedFamilies([.accessoryCircular, .accessoryInline, .accessoryCorner])
124 #else
114 .supportedFamilies([.systemSmall, .accessoryCircular, .accessoryInline]) 125 .supportedFamilies([.systemSmall, .accessoryCircular, .accessoryInline])
126 #endif
115 } 127 }
116} 128}
117 129
@@ -158,9 +170,28 @@ struct PlanetsProvider: TimelineProvider {
158} 170}
159 171
160struct PlanetsWidgetView: View { 172struct PlanetsWidgetView: View {
173 @Environment(\.widgetFamily) var family
161 var entry: PlanetsEntry 174 var entry: PlanetsEntry
162 175
163 var body: some View { 176 var body: some View {
177 if family == .accessoryRectangular { compact } else { full }
178 }
179
180 /// Watch complication and Lock Screen: up to three planets, no header.
181 private var compact: some View {
182 VStack(alignment: .leading, spacing: 1) {
183 if entry.rows.isEmpty { Text("No planets tonight").font(.system(size: 13)) }
184 ForEach(entry.rows.prefix(3), id: \.self) { row in
185 HStack {
186 Text(row.name).font(.system(size: 13, weight: .medium))
187 Spacer()
188 Text(row.detail).font(.system(size: 12).monospacedDigit())
189 }
190 }
191 }
192 }
193
194 private var full: some View {
164 VStack(alignment: .leading, spacing: 4) { 195 VStack(alignment: .leading, spacing: 4) {
165 Text("Planets tonight").font(.system(size: 11, weight: .medium)).textCase(.uppercase).opacity(0.6) 196 Text("Planets tonight").font(.system(size: 11, weight: .medium)).textCase(.uppercase).opacity(0.6)
166 if entry.rows.isEmpty { 197 if entry.rows.isEmpty {
@@ -190,6 +221,10 @@ struct PlanetsWidget: Widget {
190 } 221 }
191 .configurationDisplayName("Planets tonight") 222 .configurationDisplayName("Planets tonight")
192 .description("Which planets are up tonight and when they rise.") 223 .description("Which planets are up tonight and when they rise.")
224 #if os(watchOS)
225 .supportedFamilies([.accessoryRectangular])
226 #else
193 .supportedFamilies([.systemSmall, .systemMedium]) 227 .supportedFamilies([.systemSmall, .systemMedium])
228 #endif
194 } 229 }
195} 230}
App/project.yml +53
@@ -3,6 +3,7 @@ options:
3 bundleIdPrefix: sh.krz 3 bundleIdPrefix: sh.krz
4 deploymentTarget: 4 deploymentTarget:
5 iOS: "26.0" 5 iOS: "26.0"
6 watchOS: "26.0"
6packages: 7packages:
7 MoonriseKit: 8 MoonriseKit:
8 path: ../Packages/MoonriseKit 9 path: ../Packages/MoonriseKit
@@ -25,6 +26,7 @@ targets:
25 - package: MoonriseKit 26 - package: MoonriseKit
26 product: ObservingLog 27 product: ObservingLog
27 - target: MoonriseWidgets 28 - target: MoonriseWidgets
29 - target: MoonriseWatch
28 entitlements: 30 entitlements:
29 path: Sources/Moonrise.entitlements 31 path: Sources/Moonrise.entitlements
30 properties: 32 properties:
@@ -88,6 +90,57 @@ targets:
88 TARGETED_DEVICE_FAMILY: "1,2" 90 TARGETED_DEVICE_FAMILY: "1,2"
89 MARKETING_VERSION: "1.0" 91 MARKETING_VERSION: "1.0"
90 CURRENT_PROJECT_VERSION: "1" 92 CURRENT_PROJECT_VERSION: "1"
93 MoonriseWatch:
94 type: application
95 platform: watchOS
96 sources: [Watch]
97 dependencies:
98 - package: MoonriseKit
99 product: SkyEngine
100 - package: MoonriseKit
101 product: SkyRenderer
102 - target: MoonriseWatchWidgets
103 settings:
104 base:
105 PRODUCT_BUNDLE_IDENTIFIER: sh.krz.moonrise.watchkitapp
106 DEVELOPMENT_TEAM: ZCNAX3VL9D
107 CODE_SIGN_STYLE: Automatic
108 SWIFT_VERSION: "6.0"
109 MARKETING_VERSION: "1.0"
110 CURRENT_PROJECT_VERSION: "1"
111 GENERATE_INFOPLIST_FILE: YES
112 INFOPLIST_KEY_CFBundleDisplayName: Moonrise
113 INFOPLIST_KEY_WKCompanionAppBundleIdentifier: sh.krz.moonrise
114 INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp: YES
115 INFOPLIST_KEY_NSLocationWhenInUseUsageDescription: Moonrise uses your location to work out tonight's sky.
116 ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
117 MoonriseWatchWidgets:
118 type: app-extension
119 platform: watchOS
120 sources:
121 - Widgets/MoonriseWidgets.swift
122 dependencies:
123 - package: MoonriseKit
124 product: SkyEngine
125 - package: MoonriseKit
126 product: SkyRenderer
127 - sdk: WidgetKit.framework
128 - sdk: SwiftUI.framework
129 info:
130 path: Watch/Widgets-Info.plist
131 properties:
132 CFBundleDisplayName: Moonrise
133 NSWidgetWantsLocation: true
134 NSExtension:
135 NSExtensionPointIdentifier: com.apple.widgetkit-extension
136 settings:
137 base:
138 PRODUCT_BUNDLE_IDENTIFIER: sh.krz.moonrise.watchkitapp.widgets
139 DEVELOPMENT_TEAM: ZCNAX3VL9D
140 CODE_SIGN_STYLE: Automatic
141 SWIFT_VERSION: "6.0"
142 MARKETING_VERSION: "1.0"
143 CURRENT_PROJECT_VERSION: "1"
91 MoonriseUITests: 144 MoonriseUITests:
92 type: bundle.ui-testing 145 type: bundle.ui-testing
93 platform: iOS 146 platform: iOS
PLAN.org +2 −1
@@ -403,7 +403,8 @@ Sky, System, Sun, Launches, Library core (above).
403- Mars: latest rover raw images, local time at each rover. 403- Mars: latest rover raw images, local time at each rover.
404- Library: exoplanets, rockets to scale, exploration timeline, scale 404- Library: exoplanets, rockets to scale, exploration timeline, scale
405 view. Done. 405 view. Done.
406- iPad, Mac, Apple Watch complications. 406- iPad, Mac, Apple Watch complications. Done: Mac as "Designed for iPad";
407 Watch app (Moon, Sun, planets tonight) with Moon and Planets complications.
407 408
408** v3: across time and cultures 409** v3: across time and cultures
409- Sky cultures: Chinese, Polynesian, Arabic, Norse and other 410- Sky cultures: Chinese, Polynesian, Arabic, Norse and other
Packages/MoonriseKit/Package.swift +1 −1
@@ -3,7 +3,7 @@ import PackageDescription
3 3
4let package = Package( 4let package = Package(
5 name: "MoonriseKit", 5 name: "MoonriseKit",
6 platforms: [.iOS(.v26), .macOS(.v26)], 6 platforms: [.iOS(.v26), .macOS(.v26), .watchOS(.v26)],
7 products: [ 7 products: [
8 .library(name: "SkyEngine", targets: ["SkyEngine"]), 8 .library(name: "SkyEngine", targets: ["SkyEngine"]),
9 .library(name: "SkyRenderer", targets: ["SkyRenderer"]), 9 .library(name: "SkyRenderer", targets: ["SkyRenderer"]),