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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user