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:
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal 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
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
97
MoonWatch/MoonDiskView.swift
Normal file
97
MoonWatch/MoonDiskView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ struct MoonWatchApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
92
MoonWatch/StarFieldView.swift
Normal file
92
MoonWatch/StarFieldView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
29
MoonWatchWidget/Info.plist
Normal file
29
MoonWatchWidget/Info.plist
Normal 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>
|
||||
203
MoonWatchWidget/MoonWatchWidget.swift
Normal file
203
MoonWatchWidget/MoonWatchWidget.swift
Normal 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
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