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

@@ -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))
}