diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..634fbb6 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/MoonWatch.xcodeproj/project.pbxproj b/MoonWatch.xcodeproj/project.pbxproj index 578714c..52d11b3 100644 --- a/MoonWatch.xcodeproj/project.pbxproj +++ b/MoonWatch.xcodeproj/project.pbxproj @@ -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 = ""; + }; + D0CAFE000000000000000002 /* MoonWatchWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + D0CAFE000000000000000010 /* Exceptions for "MoonWatchWidget" folder in "MoonWatchWidget" target */, + ); + path = MoonWatchWidget; + sourceTree = ""; + }; 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 = ""; @@ -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 = ( diff --git a/MoonWatch.xcodeproj/xcuserdata/oceans2alaska.xcuserdatad/xcschemes/xcschememanagement.plist b/MoonWatch.xcodeproj/xcuserdata/oceans2alaska.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 76d3a94..0000000 --- a/MoonWatch.xcodeproj/xcuserdata/oceans2alaska.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - SchemeUserState - - MoonWatch.xcscheme_^#shared#^_ - - orderHint - 0 - - - - diff --git a/MoonWatch/ContentView.swift b/MoonWatch/ContentView.swift index 930f3c3..09f56c7 100644 --- a/MoonWatch/ContentView.swift +++ b/MoonWatch/ContentView.swift @@ -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() } } diff --git a/MoonWatch/MoonDiskView.swift b/MoonWatch/MoonDiskView.swift new file mode 100644 index 0000000..ce62ba1 --- /dev/null +++ b/MoonWatch/MoonDiskView.swift @@ -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) + } +} diff --git a/MoonWatch/MoonWatchApp.swift b/MoonWatch/MoonWatchApp.swift index a92a28c..ec7345a 100644 --- a/MoonWatch/MoonWatchApp.swift +++ b/MoonWatch/MoonWatchApp.swift @@ -12,6 +12,7 @@ struct MoonWatchApp: App { var body: some Scene { WindowGroup { ContentView() + .preferredColorScheme(.dark) } } } diff --git a/MoonWatch/StarFieldView.swift b/MoonWatch/StarFieldView.swift new file mode 100644 index 0000000..5d25f80 --- /dev/null +++ b/MoonWatch/StarFieldView.swift @@ -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.. 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() + } +} diff --git a/MoonWatchTests/MoonWatchTests.swift b/MoonWatchTests/MoonWatchTests.swift index 4db4baa..85aefcf 100644 --- a/MoonWatchTests/MoonWatchTests.swift +++ b/MoonWatchTests/MoonWatchTests.swift @@ -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) + } } diff --git a/MoonWatchWidget/Info.plist b/MoonWatchWidget/Info.plist new file mode 100644 index 0000000..3cba6c5 --- /dev/null +++ b/MoonWatchWidget/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + MoonWatch Widget + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/MoonWatchWidget/MoonWatchWidget.swift b/MoonWatchWidget/MoonWatchWidget.swift new file mode 100644 index 0000000..4cd132a --- /dev/null +++ b/MoonWatchWidget/MoonWatchWidget.swift @@ -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) -> 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)) +} diff --git a/Shared/MoonImageDisk.swift b/Shared/MoonImageDisk.swift new file mode 100644 index 0000000..46ceb23 --- /dev/null +++ b/Shared/MoonImageDisk.swift @@ -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) + } +} diff --git a/Shared/MoonPhase.swift b/Shared/MoonPhase.swift new file mode 100644 index 0000000..d464ba0 --- /dev/null +++ b/Shared/MoonPhase.swift @@ -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 + } +} diff --git a/Shared/MoonPhaseCalculator.swift b/Shared/MoonPhaseCalculator.swift new file mode 100644 index 0000000..9ab5144 --- /dev/null +++ b/Shared/MoonPhaseCalculator.swift @@ -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 + ) + } +} diff --git a/Shared/MoonShadowShape.swift b/Shared/MoonShadowShape.swift new file mode 100644 index 0000000..25ca75d --- /dev/null +++ b/Shared/MoonShadowShape.swift @@ -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 + } +} diff --git a/Shared/SharedAssets.xcassets/Contents.json b/Shared/SharedAssets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Shared/SharedAssets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Shared/SharedAssets.xcassets/Moon.imageset/Contents.json b/Shared/SharedAssets.xcassets/Moon.imageset/Contents.json new file mode 100644 index 0000000..8fe51ff --- /dev/null +++ b/Shared/SharedAssets.xcassets/Moon.imageset/Contents.json @@ -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 + } +} diff --git a/Shared/SharedAssets.xcassets/Moon.imageset/Moon.png b/Shared/SharedAssets.xcassets/Moon.imageset/Moon.png new file mode 100644 index 0000000..a9504c9 Binary files /dev/null and b/Shared/SharedAssets.xcassets/Moon.imageset/Moon.png differ