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