Add MoonWatch app, widget extension, shared module, and .gitignore

Adds the real MoonWatch implementation on top of the initial Xcode
template: new SwiftUI views (MoonDiskView, StarFieldView), the
MoonWatchWidget extension, and a Shared module containing the moon
phase calculator, shadow shape, and shared asset catalog. Also adds a
Swift/Xcode/macOS .gitignore and untracks per-user xcuserdata files.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-07 21:05:16 -07:00
parent 1698e3ce15
commit 87f8c7173c
17 changed files with 1211 additions and 24 deletions

105
Shared/MoonImageDisk.swift Normal file
View File

@@ -0,0 +1,105 @@
//
// MoonImageDisk.swift
// Shared moon disk view used by both the app and widget.
//
// Renders the bundled "Moon" image masked to a circle, applies the
// geometric phase shadow (multiplied for soft Earthshine), a subtle
// spherical limb darkening, and a crisp limb stroke.
//
import SwiftUI
public struct MoonImageDisk: View {
public var normalizedPhase: Double
/// Mirror horizontally for southern hemisphere observers.
public var southernHemisphere: Bool
/// Scale the moon photo inside the circle (e.g. if the source has
/// a small black border around the disk).
public var imageScale: CGFloat
public init(
normalizedPhase: Double,
southernHemisphere: Bool = false,
imageScale: CGFloat = 1.30
) {
self.normalizedPhase = normalizedPhase
self.southernHemisphere = southernHemisphere
self.imageScale = imageScale
}
public var body: some View {
GeometryReader { geo in
let side = min(geo.size.width, geo.size.height)
ZStack {
Image("Moon", bundle: .main)
.resizable()
.interpolation(.high)
.aspectRatio(contentMode: .fill)
.frame(width: side, height: side)
.scaleEffect(imageScale)
MoonShadowShape(phaseFraction: normalizedPhase)
.fill(
RadialGradient(
colors: [
Color(red: 0.04, green: 0.04, blue: 0.10),
Color(red: 0.01, green: 0.01, blue: 0.04),
],
center: .center,
startRadius: 0,
endRadius: side * 0.6
)
)
.blur(radius: max(1.0, side * 0.018))
.blendMode(.multiply)
}
.frame(width: side, height: side)
.compositingGroup()
.clipShape(Circle())
.scaleEffect(x: southernHemisphere ? -1 : 1, y: 1)
.overlay {
Circle()
.strokeBorder(
LinearGradient(
colors: [
Color.white.opacity(0.40),
Color.white.opacity(0.05),
Color.black.opacity(0.30),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: max(0.6, side * 0.005)
)
.frame(width: side, height: side)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.aspectRatio(1, contentMode: .fit)
}
}
#Preview("Waxing Crescent") {
ZStack {
Color(red: 0.04, green: 0.05, blue: 0.12).ignoresSafeArea()
MoonImageDisk(normalizedPhase: 0.12)
.frame(width: 280, height: 280)
}
}
#Preview("Waxing Gibbous") {
ZStack {
Color(red: 0.04, green: 0.05, blue: 0.12).ignoresSafeArea()
MoonImageDisk(normalizedPhase: 0.35)
.frame(width: 280, height: 280)
}
}
#Preview("Full") {
ZStack {
Color(red: 0.04, green: 0.05, blue: 0.12).ignoresSafeArea()
MoonImageDisk(normalizedPhase: 0.50)
.frame(width: 280, height: 280)
}
}

50
Shared/MoonPhase.swift Normal file
View File

@@ -0,0 +1,50 @@
//
// MoonPhase.swift
// Shared between MoonWatch and MoonWatchWidget.
//
import Foundation
public enum MoonPhaseName: String, CaseIterable, Sendable {
case newMoon = "New Moon"
case waxingCrescent = "Waxing Crescent"
case firstQuarter = "First Quarter"
case waxingGibbous = "Waxing Gibbous"
case fullMoon = "Full Moon"
case waningGibbous = "Waning Gibbous"
case thirdQuarter = "Third Quarter"
case waningCrescent = "Waning Crescent"
}
public struct MoonPhaseSnapshot: Equatable, Sendable {
public let date: Date
/// Position in synodic month, 0 at new moon approaching 1 at the next new moon.
public let normalizedPhase: Double
/// Days since last new moon, in `[0, synodicMonth)`.
public let moonAgeDays: Double
public let namedPhase: MoonPhaseName
/// Fraction of the apparent disk illuminated, 01.
public let illuminatedFraction: Double
/// The next phase that will begin after the current one.
public let nextPhaseName: MoonPhaseName
/// Days until the next phase transition.
public let daysToNextPhase: Double
public init(
date: Date,
normalizedPhase: Double,
moonAgeDays: Double,
namedPhase: MoonPhaseName,
illuminatedFraction: Double,
nextPhaseName: MoonPhaseName,
daysToNextPhase: Double
) {
self.date = date
self.normalizedPhase = normalizedPhase
self.moonAgeDays = moonAgeDays
self.namedPhase = namedPhase
self.illuminatedFraction = illuminatedFraction
self.nextPhaseName = nextPhaseName
self.daysToNextPhase = daysToNextPhase
}
}

View File

@@ -0,0 +1,102 @@
//
// MoonPhaseCalculator.swift
// Shared between MoonWatch and MoonWatchWidget.
//
import Foundation
public enum MoonPhaseCalculator: Sendable {
/// Mean synodic month length in days (Meeus).
public static let synodicMonth = 29.530588853
/// Julian date at Unix epoch (1970-01-01 00:00 UTC).
private static let julianDayUnixEpoch = 24_405_87.5
/// Known new moon reference: 2000-01-06 18:14 UTC, JD 2451550.09765.
private static let julianDayKnownNewMoon = 2_451_550.09765
public static func julianDay(for date: Date) -> Double {
date.timeIntervalSince1970 / 86_400.0 + julianDayUnixEpoch
}
/// Days since the reference new moon, wrapped to `[0, synodicMonth)`.
public static func moonAgeDays(for date: Date) -> Double {
let jd = julianDay(for: date)
var age = jd - julianDayKnownNewMoon
age = age.truncatingRemainder(dividingBy: synodicMonth)
if age < 0 { age += synodicMonth }
return age
}
/// Normalized phase in `[0, 1)`; 0 is new moon.
public static func normalizedPhase(for date: Date) -> Double {
moonAgeDays(for: date) / synodicMonth
}
/// Illuminated fraction of the apparent disk (simple geometric model).
public static func illuminatedFraction(for date: Date) -> Double {
let t = normalizedPhase(for: date)
let angle = 2 * Double.pi * t
return (1 - cos(angle)) / 2
}
public static func namedPhase(normalizedPhase t: Double) -> MoonPhaseName {
let x = t.truncatingRemainder(dividingBy: 1)
let u = x < 0 ? x + 1 : x
// Cardinal phases (New, First Quarter, Full, Last Quarter) are astronomical
// moments, not equal eighths of the cycle. Treat them as narrow windows of
// 1/16 of the cycle (~1.85 days) centered at 0, 0.25, 0.5, 0.75; the four
// intermediate phases fill the 3/16 gaps between them.
let step = u * 32
switch step {
case ..<1, 31...: return .newMoon
case 1..<7: return .waxingCrescent
case 7..<9: return .firstQuarter
case 9..<15: return .waxingGibbous
case 15..<17: return .fullMoon
case 17..<23: return .waningGibbous
case 23..<25: return .thirdQuarter
default: return .waningCrescent
}
}
/// Returns the next phase transition: which phase comes next and how many
/// days until the boundary is crossed.
public static func nextPhaseTransition(normalizedPhase t: Double) -> (phase: MoonPhaseName, daysAway: Double) {
let raw = t.truncatingRemainder(dividingBy: 1)
let u = raw < 0 ? raw + 1 : raw
let step = u * 32
// Each entry is (boundary in 32nds, the phase that begins at that boundary)
let transitions: [(Double, MoonPhaseName)] = [
(1, .waxingCrescent),
(7, .firstQuarter),
(9, .waxingGibbous),
(15, .fullMoon),
(17, .waningGibbous),
(23, .thirdQuarter),
(25, .waningCrescent),
(31, .newMoon),
]
for (boundary, phase) in transitions where step < boundary {
return (phase, (boundary / 32.0 - u) * synodicMonth)
}
// Late new-moon window (step 31): next is waxing crescent at 1/32 next cycle
return (.waxingCrescent, (1.0 + 1.0 / 32.0 - u) * synodicMonth)
}
public static func snapshot(for date: Date) -> MoonPhaseSnapshot {
let age = moonAgeDays(for: date)
let norm = age / synodicMonth
let (nextPhase, daysToNext) = nextPhaseTransition(normalizedPhase: norm)
return MoonPhaseSnapshot(
date: date,
normalizedPhase: norm,
moonAgeDays: age,
namedPhase: namedPhase(normalizedPhase: norm),
illuminatedFraction: illuminatedFraction(for: date),
nextPhaseName: nextPhase,
daysToNextPhase: daysToNext
)
}
}

View File

@@ -0,0 +1,62 @@
//
// MoonShadowShape.swift
// Shared between MoonWatch and MoonWatchWidget.
//
import SwiftUI
/// Geometric terminator: half-circle along the dark limb stitched to a
/// half-ellipse whose horizontal axis = cos(2π·t)·R. Renders correctly at
/// new (full dark), quarter (straight terminator), gibbous, and full
/// (no shadow). Slight `oversizeFactor` lets a parent `Circle` clip keep
/// the limb sharp while a small `.blur` softens the terminator.
public struct MoonShadowShape: Shape {
public var phaseFraction: Double
public var oversizeFactor: CGFloat
public init(phaseFraction: Double, oversizeFactor: CGFloat = 1.04) {
self.phaseFraction = phaseFraction
self.oversizeFactor = oversizeFactor
}
public var animatableData: Double {
get { phaseFraction }
set { phaseFraction = newValue }
}
public func path(in rect: CGRect) -> Path {
let r = min(rect.width, rect.height) / 2
let outer = r * oversizeFactor
let cx = rect.midX
let cy = rect.midY
let raw = phaseFraction.truncatingRemainder(dividingBy: 1)
let u = raw < 0 ? raw + 1 : raw
let cosT = CGFloat(cos(2 * Double.pi * u))
let waning = u > 0.5
let bulge: CGFloat = waning ? -cosT * outer : cosT * outer
let kappa: CGFloat = 0.5522847498
var path = Path()
let top = CGPoint(x: cx, y: cy - outer)
path.move(to: top)
path.addArc(
center: CGPoint(x: cx, y: cy),
radius: outer,
startAngle: .degrees(270),
endAngle: .degrees(90),
clockwise: !waning
)
path.addCurve(
to: CGPoint(x: cx + bulge, y: cy),
control1: CGPoint(x: cx + bulge * kappa, y: cy + outer),
control2: CGPoint(x: cx + bulge, y: cy + outer * kappa)
)
path.addCurve(
to: top,
control1: CGPoint(x: cx + bulge, y: cy - outer * kappa),
control2: CGPoint(x: cx + bulge * kappa, y: cy - outer)
)
path.closeSubpath()
return path
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "Moon.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB