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

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# macOS
.DS_Store
# Xcode user state / per-user files
xcuserdata/
*.xcuserstate
*.xcuserdatad/
*.xcscmblueprint
*.xccheckout
# Xcode build output
build/
DerivedData/
*.moved-aside
*.hmap
*.ipa
*.dSYM.zip
*.dSYM
# Project-specific local build artifacts (used by Cursor/xcodebuild here)
.build_artifacts/
# Swift Package Manager
.build/
Packages/
*.xcodeproj/project.xcworkspace/xcuserdata/
.swiftpm/
# Note: keep Package.resolved committed for apps (not ignored).
# CocoaPods (only ignore if you don't vendor Pods/)
# Pods/
# Carthage
Carthage/Build/
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
# Misc
*.log

View File

@@ -6,7 +6,18 @@
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
D0CAFE00000000000000000E /* MoonWatchWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0CAFE000000000000000003 /* MoonWatchWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
D0CAFE000000000000000009 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = EBC8997D2F9E7FA200246C26 /* Project object */;
proxyType = 1;
remoteGlobalIDString = D0CAFE000000000000000008;
remoteInfo = MoonWatchWidget;
};
EBC899932F9E7FA300246C26 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = EBC8997D2F9E7FA200246C26 /* Project object */;
@@ -23,13 +34,51 @@
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
D0CAFE000000000000000007 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
D0CAFE00000000000000000E /* MoonWatchWidget.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
D0CAFE000000000000000003 /* MoonWatchWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MoonWatchWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
EBC899852F9E7FA200246C26 /* MoonWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MoonWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
EBC899922F9E7FA300246C26 /* MoonWatchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MoonWatchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EBC8999C2F9E7FA300246C26 /* MoonWatchUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MoonWatchUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
D0CAFE000000000000000010 /* Exceptions for "MoonWatchWidget" folder in "MoonWatchWidget" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = D0CAFE000000000000000008 /* MoonWatchWidget */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
D0CAFE000000000000000001 /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Shared;
sourceTree = "<group>";
};
D0CAFE000000000000000002 /* MoonWatchWidget */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
D0CAFE000000000000000010 /* Exceptions for "MoonWatchWidget" folder in "MoonWatchWidget" target */,
);
path = MoonWatchWidget;
sourceTree = "<group>";
};
EBC899872F9E7FA200246C26 /* MoonWatch */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = MoonWatch;
@@ -48,6 +97,13 @@
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
D0CAFE000000000000000005 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EBC899822F9E7FA200246C26 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -75,7 +131,9 @@
EBC8997C2F9E7FA200246C26 = {
isa = PBXGroup;
children = (
D0CAFE000000000000000001 /* Shared */,
EBC899872F9E7FA200246C26 /* MoonWatch */,
D0CAFE000000000000000002 /* MoonWatchWidget */,
EBC899952F9E7FA300246C26 /* MoonWatchTests */,
EBC8999F2F9E7FA300246C26 /* MoonWatchUITests */,
EBC899862F9E7FA200246C26 /* Products */,
@@ -88,6 +146,7 @@
EBC899852F9E7FA200246C26 /* MoonWatch.app */,
EBC899922F9E7FA300246C26 /* MoonWatchTests.xctest */,
EBC8999C2F9E7FA300246C26 /* MoonWatchUITests.xctest */,
D0CAFE000000000000000003 /* MoonWatchWidget.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -95,6 +154,29 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
D0CAFE000000000000000008 /* MoonWatchWidget */ = {
isa = PBXNativeTarget;
buildConfigurationList = D0CAFE00000000000000000D /* Build configuration list for PBXNativeTarget "MoonWatchWidget" */;
buildPhases = (
D0CAFE000000000000000004 /* Sources */,
D0CAFE000000000000000005 /* Frameworks */,
D0CAFE000000000000000006 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
D0CAFE000000000000000001 /* Shared */,
D0CAFE000000000000000002 /* MoonWatchWidget */,
);
name = MoonWatchWidget;
packageProductDependencies = (
);
productName = MoonWatchWidget;
productReference = D0CAFE000000000000000003 /* MoonWatchWidget.appex */;
productType = "com.apple.product-type.app-extension";
};
EBC899842F9E7FA200246C26 /* MoonWatch */ = {
isa = PBXNativeTarget;
buildConfigurationList = EBC899A62F9E7FA300246C26 /* Build configuration list for PBXNativeTarget "MoonWatch" */;
@@ -102,12 +184,15 @@
EBC899812F9E7FA200246C26 /* Sources */,
EBC899822F9E7FA200246C26 /* Frameworks */,
EBC899832F9E7FA200246C26 /* Resources */,
D0CAFE000000000000000007 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
D0CAFE00000000000000000A /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
D0CAFE000000000000000001 /* Shared */,
EBC899872F9E7FA200246C26 /* MoonWatch */,
);
name = MoonWatch;
@@ -173,6 +258,9 @@
LastSwiftUpdateCheck = 2640;
LastUpgradeCheck = 2640;
TargetAttributes = {
D0CAFE000000000000000008 = {
CreatedOnToolsVersion = 26.4.1;
};
EBC899842F9E7FA200246C26 = {
CreatedOnToolsVersion = 26.4.1;
};
@@ -201,6 +289,7 @@
projectRoot = "";
targets = (
EBC899842F9E7FA200246C26 /* MoonWatch */,
D0CAFE000000000000000008 /* MoonWatchWidget */,
EBC899912F9E7FA300246C26 /* MoonWatchTests */,
EBC8999B2F9E7FA300246C26 /* MoonWatchUITests */,
);
@@ -208,6 +297,13 @@
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
D0CAFE000000000000000006 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EBC899832F9E7FA200246C26 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -232,6 +328,13 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
D0CAFE000000000000000004 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EBC899812F9E7FA200246C26 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -256,6 +359,11 @@
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
D0CAFE00000000000000000A /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D0CAFE000000000000000008 /* MoonWatchWidget */;
targetProxy = D0CAFE000000000000000009 /* PBXContainerItemProxy */;
};
EBC899942F9E7FA300246C26 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = EBC899842F9E7FA200246C26 /* MoonWatch */;
@@ -269,6 +377,66 @@
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
D0CAFE00000000000000000B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MoonWatchWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "MoonWatch Widget";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 26.4;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "dave-enterprise.MoonWatch.MoonWatchWidget";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
D0CAFE00000000000000000C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MoonWatchWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "MoonWatch Widget";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 26.4;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "dave-enterprise.MoonWatch.MoonWatchWidget";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
EBC899A42F9E7FA300246C26 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -533,6 +701,15 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
D0CAFE00000000000000000D /* Build configuration list for PBXNativeTarget "MoonWatchWidget" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D0CAFE00000000000000000B /* Debug */,
D0CAFE00000000000000000C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
EBC899802F9E7FA200246C26 /* Build configuration list for PBXProject "MoonWatch" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>MoonWatch.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -8,14 +8,195 @@
import SwiftUI
struct ContentView: View {
@AppStorage("moonSouthernHemisphere") private var southernHemisphere = false
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
TimelineView(.periodic(from: .now, by: 60)) { context in
let snapshot = MoonPhaseCalculator.snapshot(for: context.date)
ZStack {
Color.black
.ignoresSafeArea()
NightSkyBackground()
ScrollView {
VStack(spacing: 22) {
FloatingMoon(normalizedPhase: snapshot.normalizedPhase)
.frame(maxWidth: 320, maxHeight: 320)
.padding(.top, 32)
.padding(.bottom, 4)
VStack(spacing: 5) {
Text(snapshot.namedPhase.rawValue)
.font(.title.weight(.semibold))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
Text(detailLine(snapshot))
.font(.subheadline)
.foregroundStyle(.white.opacity(0.75))
.multilineTextAlignment(.center)
Text(nextPhaseLine(snapshot))
.font(.caption)
.foregroundStyle(.white.opacity(0.55))
.multilineTextAlignment(.center)
Text(dateLine(snapshot.date))
.font(.caption2)
.foregroundStyle(.white.opacity(0.40))
.multilineTextAlignment(.center)
}
.padding(.horizontal, 28)
.padding(.vertical, 18)
.glassEffect(
.regular,
in: RoundedRectangle(cornerRadius: 28, style: .continuous)
)
.padding(.horizontal, 24)
HemisphereSettingsCard(
southernHemisphere: $southernHemisphere
)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
.padding(.bottom, 40)
}
.scrollIndicators(.hidden)
.scrollContentBackground(.hidden)
.background(Color.clear)
}
}
.padding()
.environment(\.moonSouthernHemisphere, southernHemisphere)
.preferredColorScheme(.dark)
}
private func detailLine(_ s: MoonPhaseSnapshot) -> String {
let pct = Int((s.illuminatedFraction * 100).rounded())
let fmt = NumberFormatter()
fmt.minimumFractionDigits = 1
fmt.maximumFractionDigits = 1
let age = fmt.string(from: NSNumber(value: s.moonAgeDays))
?? String(format: "%.1f", s.moonAgeDays)
return "\(pct)% illuminated · \(age) days old"
}
private func nextPhaseLine(_ s: MoonPhaseSnapshot) -> String {
let days = s.daysToNextPhase
if days < 1 {
let hours = Int((days * 24).rounded())
return "Next: \(s.nextPhaseName.rawValue) in ~\(hours)h"
} else {
let d = String(format: "%.1f", days)
return "Next: \(s.nextPhaseName.rawValue) in ~\(d) days"
}
}
private func dateLine(_ date: Date) -> String {
let df = DateFormatter()
df.dateStyle = .medium
df.timeStyle = .short
return "Updated \(df.string(from: date))"
}
}
private struct FloatingMoon: View {
var normalizedPhase: Double
var body: some View {
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { ctx in
let t = ctx.date.timeIntervalSinceReferenceDate
let dy = CGFloat(sin(t * 0.55) * 7)
let dx = CGFloat(sin(t * 0.37 + 1.2) * 4)
MoonDiskView(normalizedPhase: normalizedPhase)
.offset(x: dx, y: dy)
}
}
}
private struct HemisphereSettingsCard: View {
@Binding var southernHemisphere: Bool
var body: some View {
Toggle(isOn: $southernHemisphere) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Southern hemisphere")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
Text("Mirror waxing/waning orientation")
.font(.caption)
.foregroundStyle(.white.opacity(0.55))
}
} icon: {
Image(systemName: "globe.americas.fill")
.foregroundStyle(.white.opacity(0.85))
}
}
.tint(Color(red: 0.55, green: 0.62, blue: 1.0))
.padding(.horizontal, 20)
.padding(.vertical, 16)
.glassEffect(
.regular,
in: RoundedRectangle(cornerRadius: 24, style: .continuous)
)
}
}
private struct NightSkyBackground: View {
var body: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.02, green: 0.02, blue: 0.06),
Color(red: 0.04, green: 0.04, blue: 0.14),
Color(red: 0.06, green: 0.04, blue: 0.18),
Color(red: 0.01, green: 0.01, blue: 0.04),
],
startPoint: .top,
endPoint: .bottom
)
RadialGradient(
colors: [
Color(red: 0.40, green: 0.18, blue: 0.55).opacity(0.45),
Color(red: 0.30, green: 0.12, blue: 0.45).opacity(0.18),
Color.clear,
],
center: UnitPoint(x: 0.85, y: 0.15),
startRadius: 0,
endRadius: 520
)
.blendMode(.screen)
.blur(radius: 40)
RadialGradient(
colors: [
Color(red: 0.10, green: 0.35, blue: 0.70).opacity(0.40),
Color(red: 0.06, green: 0.20, blue: 0.50).opacity(0.16),
Color.clear,
],
center: UnitPoint(x: 0.05, y: 0.90),
startRadius: 0,
endRadius: 540
)
.blendMode(.screen)
.blur(radius: 40)
RadialGradient(
colors: [
Color(red: 0.78, green: 0.42, blue: 0.85).opacity(0.20),
Color.clear,
],
center: UnitPoint(x: 0.6, y: 0.55),
startRadius: 0,
endRadius: 380
)
.blendMode(.screen)
.blur(radius: 60)
StarFieldView(starCount: 200)
}
.ignoresSafeArea()
}
}

View File

@@ -0,0 +1,97 @@
//
// MoonDiskView.swift
// MoonWatch
//
import SwiftUI
private struct MoonSouthernHemisphereKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
/// When true, waxing/waning orientation matches southern-sky convention (mirrored).
var moonSouthernHemisphere: Bool {
get { self[MoonSouthernHemisphereKey.self] }
set { self[MoonSouthernHemisphereKey.self] = newValue }
}
}
/// In-app moon view: photographic disk + atmospheric halo, drop shadows,
/// and limb stroke. The image+shadow rendering lives in `MoonImageDisk`
/// (in `Shared/`) so the widget can reuse it.
struct MoonDiskView: View {
/// Synodic phase in `[0, 1)`; 0 = new, 0.5 = full.
var normalizedPhase: Double
@Environment(\.moonSouthernHemisphere) private var southernHemisphere
var body: some View {
GeometryReader { geo in
let side = min(geo.size.width, geo.size.height)
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color(red: 0.98, green: 0.94, blue: 0.78).opacity(0.32),
Color(red: 0.55, green: 0.50, blue: 0.85).opacity(0.12),
Color.clear,
],
center: .center,
startRadius: side * 0.32,
endRadius: side * 0.95
)
)
.frame(width: side * 1.9, height: side * 1.9)
.blendMode(.screen)
.blur(radius: side * 0.05)
MoonImageDisk(
normalizedPhase: normalizedPhase,
southernHemisphere: southernHemisphere
)
.frame(width: side, height: side)
}
.shadow(color: Color(red: 0.98, green: 0.94, blue: 0.78).opacity(0.30),
radius: side * 0.10)
.shadow(color: Color(red: 0.45, green: 0.55, blue: 0.95).opacity(0.30),
radius: side * 0.22)
.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()
MoonDiskView(normalizedPhase: 0.12)
.frame(width: 280, height: 280)
}
}
#Preview("First Quarter") {
ZStack {
Color(red: 0.04, green: 0.05, blue: 0.12).ignoresSafeArea()
MoonDiskView(normalizedPhase: 0.25)
.frame(width: 280, height: 280)
}
}
#Preview("Full") {
ZStack {
Color(red: 0.04, green: 0.05, blue: 0.12).ignoresSafeArea()
MoonDiskView(normalizedPhase: 0.50)
.frame(width: 280, height: 280)
}
}
#Preview("Waning Gibbous") {
ZStack {
Color(red: 0.04, green: 0.05, blue: 0.12).ignoresSafeArea()
MoonDiskView(normalizedPhase: 0.62)
.frame(width: 280, height: 280)
}
}

View File

@@ -12,6 +12,7 @@ struct MoonWatchApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.preferredColorScheme(.dark)
}
}
}

View File

@@ -0,0 +1,92 @@
//
// StarFieldView.swift
// MoonWatch
//
import SwiftUI
struct StarFieldView: View {
var starCount: Int = 160
@State private var stars: [Star] = []
private struct Star: Identifiable {
let id = UUID()
let position: CGPoint // 0...1 normalized
let radius: CGFloat
let baseOpacity: Double
let twinklePhase: Double
let twinkleSpeed: Double
let warm: Bool
}
var body: some View {
GeometryReader { geo in
TimelineView(.animation(minimumInterval: 1.0 / 12.0)) { ctx in
Canvas { context, size in
let t = ctx.date.timeIntervalSinceReferenceDate
for star in stars {
let twinkle = (sin(t * star.twinkleSpeed + star.twinklePhase) + 1) / 2
let alpha = star.baseOpacity * (0.35 + 0.65 * twinkle)
let center = CGPoint(
x: star.position.x * size.width,
y: star.position.y * size.height
)
let rect = CGRect(
x: center.x - star.radius,
y: center.y - star.radius,
width: star.radius * 2,
height: star.radius * 2
)
let color: Color = star.warm
? Color(red: 1.0, green: 0.92, blue: 0.78).opacity(alpha)
: Color(red: 0.92, green: 0.95, blue: 1.0).opacity(alpha)
context.fill(Path(ellipseIn: rect), with: .color(color))
if star.radius > 1.1 {
// Soft halo for the larger stars.
let halo = rect.insetBy(dx: -star.radius * 1.6, dy: -star.radius * 1.6)
context.fill(
Path(ellipseIn: halo),
with: .color(color.opacity(0.20))
)
}
}
}
}
.onAppear {
if stars.isEmpty {
stars = Self.generate(count: starCount)
}
}
}
.ignoresSafeArea()
.allowsHitTesting(false)
}
private static func generate(count: Int) -> [Star] {
var rng = SystemRandomNumberGenerator()
return (0..<count).map { _ in
let big = Double.random(in: 0...1, using: &rng) > 0.88
return Star(
position: CGPoint(
x: CGFloat.random(in: 0...1, using: &rng),
y: CGFloat.random(in: 0...1, using: &rng)
),
radius: big
? CGFloat.random(in: 1.4...2.2, using: &rng)
: CGFloat.random(in: 0.4...1.1, using: &rng),
baseOpacity: Double.random(in: 0.35...0.95, using: &rng),
twinklePhase: Double.random(in: 0...(2 * .pi), using: &rng),
twinkleSpeed: Double.random(in: 0.5...1.7, using: &rng),
warm: Double.random(in: 0...1, using: &rng) > 0.75
)
}
}
}
#Preview {
ZStack {
Color(red: 0.03, green: 0.04, blue: 0.10).ignoresSafeArea()
StarFieldView()
}
}

View File

@@ -10,10 +10,42 @@ import Testing
struct MoonWatchTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
// Swift Testing Documentation
// https://developer.apple.com/documentation/testing
private let iso8601: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
@Test func julianDayUnixEpoch() {
let utc = try #require(iso8601.date(from: "1970-01-01T00:00:00Z"))
let jd = MoonPhaseCalculator.julianDay(for: utc)
#expect(abs(jd - 2_440_587.5) < 0.0001)
}
/// Full moon near total lunar eclipse peak (UTC).
@Test func goldenFullMoonJanuary2019() {
let date = try #require(iso8601.date(from: "2019-01-21T05:16:00Z"))
let s = MoonPhaseCalculator.snapshot(for: date)
#expect(s.namedPhase == .fullMoon)
#expect(s.normalizedPhase > 0.5 && s.normalizedPhase < 0.52)
#expect(s.illuminatedFraction > 0.99)
}
/// New moon (UTC).
@Test func goldenNewMoonNovember2024() {
let date = try #require(iso8601.date(from: "2024-11-01T13:00:00Z"))
let s = MoonPhaseCalculator.snapshot(for: date)
#expect(s.namedPhase == .newMoon)
#expect(s.normalizedPhase >= 0 && s.normalizedPhase < 0.02)
#expect(s.illuminatedFraction < 0.02)
}
/// First quarter (UTC).
@Test func goldenFirstQuarterOctober2024() {
let date = try #require(iso8601.date(from: "2024-10-10T19:00:00Z"))
let s = MoonPhaseCalculator.snapshot(for: date)
#expect(s.namedPhase == .firstQuarter)
#expect(s.normalizedPhase > 0.25 && s.normalizedPhase < 0.28)
#expect(s.illuminatedFraction > 0.45 && s.illuminatedFraction < 0.60)
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>MoonWatch Widget</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,203 @@
//
// MoonWatchWidget.swift
// MoonWatchWidget
//
// Home Screen + Lock Screen widget showing the current moon phase.
// Pulls phase math + the photographic moon disk from the Shared module.
//
import SwiftUI
import WidgetKit
struct MoonPhaseEntry: TimelineEntry, Sendable {
let date: Date
let snapshot: MoonPhaseSnapshot
}
struct MoonPhaseProvider: TimelineProvider {
func placeholder(in context: Context) -> MoonPhaseEntry {
let now = Date()
return MoonPhaseEntry(date: now, snapshot: MoonPhaseCalculator.snapshot(for: now))
}
func getSnapshot(
in context: Context,
completion: @escaping @Sendable (MoonPhaseEntry) -> Void
) {
let now = Date()
completion(MoonPhaseEntry(date: now, snapshot: MoonPhaseCalculator.snapshot(for: now)))
}
func getTimeline(
in context: Context,
completion: @escaping @Sendable (Timeline<MoonPhaseEntry>) -> Void
) {
var entries: [MoonPhaseEntry] = []
let now = Date()
let cal = Calendar.current
// 48 hourly entries covers two full days; .atEnd triggers a fresh
// reload once the last entry is consumed, so the widget is always current.
for hourOffset in 0..<48 {
let date = cal.date(byAdding: .hour, value: hourOffset, to: now) ?? now
let snap = MoonPhaseCalculator.snapshot(for: date)
entries.append(MoonPhaseEntry(date: date, snapshot: snap))
}
completion(Timeline(entries: entries, policy: .atEnd))
}
}
struct MoonWatchWidgetEntryView: View {
var entry: MoonPhaseEntry
@Environment(\.widgetFamily) private var family
var body: some View {
switch family {
case .systemSmall:
smallView
case .systemMedium:
mediumView
case .systemLarge:
largeView
case .accessoryCircular:
circularAccessory
case .accessoryRectangular:
rectangularAccessory
case .accessoryInline:
Text("\(Image(systemName: "moon.fill")) \(entry.snapshot.namedPhase.rawValue)")
default:
smallView
}
}
private var smallView: some View {
VStack(spacing: 6) {
MoonImageDisk(normalizedPhase: entry.snapshot.normalizedPhase)
.frame(maxWidth: .infinity, maxHeight: .infinity)
Text(entry.snapshot.namedPhase.rawValue)
.font(.caption2.weight(.semibold))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.7)
Text("\(Int((entry.snapshot.illuminatedFraction * 100).rounded()))% lit")
.font(.caption2)
.foregroundStyle(.white.opacity(0.7))
}
}
private var mediumView: some View {
HStack(spacing: 14) {
MoonImageDisk(normalizedPhase: entry.snapshot.normalizedPhase)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.frame(maxWidth: 130)
VStack(alignment: .leading, spacing: 6) {
Text(entry.snapshot.namedPhase.rawValue)
.font(.headline.weight(.semibold))
.foregroundStyle(.white)
Text("\(Int((entry.snapshot.illuminatedFraction * 100).rounded()))% illuminated")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.75))
Text("Age: \(String(format: "%.1f", entry.snapshot.moonAgeDays)) days")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.6))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var largeView: some View {
VStack(spacing: 12) {
MoonImageDisk(normalizedPhase: entry.snapshot.normalizedPhase)
.frame(maxWidth: .infinity, maxHeight: .infinity)
VStack(spacing: 4) {
Text(entry.snapshot.namedPhase.rawValue)
.font(.title3.weight(.semibold))
.foregroundStyle(.white)
Text(
"\(Int((entry.snapshot.illuminatedFraction * 100).rounded()))% lit · age \(String(format: "%.1f", entry.snapshot.moonAgeDays)) days"
)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.7))
}
}
}
private var circularAccessory: some View {
ZStack {
AccessoryWidgetBackground()
MoonImageDisk(normalizedPhase: entry.snapshot.normalizedPhase)
.padding(2)
}
}
private var rectangularAccessory: some View {
HStack(spacing: 8) {
MoonImageDisk(normalizedPhase: entry.snapshot.normalizedPhase)
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 1) {
Text(entry.snapshot.namedPhase.rawValue)
.font(.caption.weight(.semibold))
Text(
"\(Int((entry.snapshot.illuminatedFraction * 100).rounded()))% · \(String(format: "%.1f", entry.snapshot.moonAgeDays))d"
)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
struct MoonWatchWidget: Widget {
let kind: String = "MoonWatchWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: MoonPhaseProvider()) { entry in
MoonWatchWidgetEntryView(entry: entry)
.containerBackground(for: .widget) {
LinearGradient(
colors: [
Color(red: 0.04, green: 0.04, blue: 0.14),
Color(red: 0.06, green: 0.04, blue: 0.18),
Color(red: 0.01, green: 0.02, blue: 0.06),
],
startPoint: .top,
endPoint: .bottom
)
}
}
.configurationDisplayName("Moon Phase")
.description("See the current phase of the moon at a glance.")
.supportedFamilies([
.systemSmall,
.systemMedium,
.systemLarge,
.accessoryCircular,
.accessoryRectangular,
.accessoryInline,
])
}
}
@main
struct MoonWatchWidgetBundle: WidgetBundle {
var body: some Widget {
MoonWatchWidget()
}
}
#Preview(as: .systemSmall) {
MoonWatchWidget()
} timeline: {
MoonPhaseEntry(date: .now, snapshot: MoonPhaseCalculator.snapshot(for: .now))
}
#Preview(as: .systemMedium) {
MoonWatchWidget()
} timeline: {
MoonPhaseEntry(date: .now, snapshot: MoonPhaseCalculator.snapshot(for: .now))
}
#Preview(as: .accessoryRectangular) {
MoonWatchWidget()
} timeline: {
MoonPhaseEntry(date: .now, snapshot: MoonPhaseCalculator.snapshot(for: .now))
}

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