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>
204 lines
6.8 KiB
Swift
204 lines
6.8 KiB
Swift
//
|
|
// 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))
|
|
}
|