Add MoonWatch app, widget extension, shared module, and .gitignore

Adds the real MoonWatch implementation on top of the initial Xcode
template: new SwiftUI views (MoonDiskView, StarFieldView), the
MoonWatchWidget extension, and a Shared module containing the moon
phase calculator, shadow shape, and shared asset catalog. Also adds a
Swift/Xcode/macOS .gitignore and untracks per-user xcuserdata files.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-07 21:05:16 -07:00
parent 1698e3ce15
commit 87f8c7173c
17 changed files with 1211 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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