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:
105
Shared/MoonImageDisk.swift
Normal file
105
Shared/MoonImageDisk.swift
Normal 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
50
Shared/MoonPhase.swift
Normal 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, 0…1.
|
||||
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
|
||||
}
|
||||
}
|
||||
102
Shared/MoonPhaseCalculator.swift
Normal file
102
Shared/MoonPhaseCalculator.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
62
Shared/MoonShadowShape.swift
Normal file
62
Shared/MoonShadowShape.swift
Normal 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
|
||||
}
|
||||
}
|
||||
6
Shared/SharedAssets.xcassets/Contents.json
Normal file
6
Shared/SharedAssets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
21
Shared/SharedAssets.xcassets/Moon.imageset/Contents.json
vendored
Normal file
21
Shared/SharedAssets.xcassets/Moon.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
Shared/SharedAssets.xcassets/Moon.imageset/Moon.png
vendored
Normal file
BIN
Shared/SharedAssets.xcassets/Moon.imageset/Moon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Reference in New Issue
Block a user