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>
206 lines
7.0 KiB
Swift
206 lines
7.0 KiB
Swift
//
|
|
// ContentView.swift
|
|
// MoonWatch
|
|
//
|
|
// Created by david on 4/26/26.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct ContentView: View {
|
|
@AppStorage("moonSouthernHemisphere") private var southernHemisphere = false
|
|
|
|
var body: some View {
|
|
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)
|
|
}
|
|
}
|
|
.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()
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ContentView()
|
|
}
|