krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

App/Widgets/EclipseLiveActivity.swift

6c4b28384d65e09486e0ad62f2ac9d10401cc80c
moonrise/App/Widgets/EclipseLiveActivity.swift history · blame · raw

129 lines · 5735 bytes

  1import ActivityKit
  2import SwiftUI
  3import WidgetKit
  4
  5struct EclipseLiveActivity: Widget {
  6    private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
  7    private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
  8
  9    var body: some WidgetConfiguration {
 10        ActivityConfiguration(for: EclipseActivityAttributes.self) { context in
 11            EclipseActivityView(attributes: context.attributes, ink: ink, accent: accent)
 12                .foregroundStyle(ink)
 13                .activityBackgroundTint(.black)
 14        } dynamicIsland: { context in
 15            let a = context.attributes
 16            let next = a.c2 ?? a.peak
 17            return DynamicIsland {
 18                DynamicIslandExpandedRegion(.leading) {
 19                    Text("\(a.kind) eclipse").font(.system(size: 14, weight: .medium))
 20                }
 21                DynamicIslandExpandedRegion(.trailing) {
 22                    countdown(to: next).font(.system(size: 14).monospacedDigit())
 23                }
 24                DynamicIslandExpandedRegion(.bottom) {
 25                    if let c2 = a.c2, let c3 = a.c3 {
 26                        HStack {
 27                            Text(a.site)
 28                            Spacer()
 29                            Text("\(clock(c2, a))–\(clock(c3, a))")
 30                        }
 31                        .font(.system(size: 12).monospacedDigit()).opacity(0.8)
 32                    } else {
 33                        Text("\(a.site) · maximum \(clock(a.peak, a))").font(.system(size: 12).monospacedDigit()).opacity(0.8)
 34                    }
 35                }
 36            } compactLeading: {
 37                IslandWidth {
 38                    Text(a.c2 == nil ? "Max" : "C2").font(.system(size: 12, weight: .medium))
 39                } limited: {
 40                    Image(systemName: "sun.max")
 41                }
 42            } compactTrailing: {
 43                countdown(to: next).font(.system(size: 12).monospacedDigit()).frame(maxWidth: 64)
 44            } minimal: {
 45                Image(systemName: "sun.max")
 46            }
 47        }
 48        .supplementalActivityFamilies([.small])
 49    }
 50}
 51
 52private struct EclipseActivityView: View {
 53    @Environment(\.activityFamily) private var family
 54    var attributes: EclipseActivityAttributes
 55    var ink: Color
 56    var accent: Color
 57
 58    private var a: EclipseActivityAttributes { attributes }
 59
 60    var body: some View {
 61        if family == .small { small } else { medium }
 62    }
 63
 64    /// Apple Watch Smart Stack and CarPlay.
 65    private var small: some View {
 66        VStack(alignment: .leading, spacing: 2) {
 67            Text("\(a.kind) eclipse").font(.system(size: 13, weight: .medium)).lineLimit(1)
 68            HStack(spacing: 4) {
 69                Text(a.c2 == nil ? "Max" : "C2")
 70                countdown(to: a.c2 ?? a.peak)
 71            }
 72            .font(.system(size: 17, weight: .medium).monospacedDigit())
 73        }
 74        .frame(maxWidth: .infinity, alignment: .leading)
 75        .padding(12)
 76    }
 77
 78    private var medium: some View {
 79        VStack(alignment: .leading, spacing: 6) {
 80            Text("\(a.kind) eclipse · \(a.site)").font(.system(size: 15, weight: .medium))
 81            HStack(alignment: .firstTextBaseline) {
 82                if let c2 = a.c2, let c3 = a.c3 {
 83                    VStack(alignment: .leading, spacing: 0) {
 84                        Text(a.kind == "Total" ? "Totality in" : "Annularity in").font(.system(size: 12)).opacity(0.7)
 85                        countdown(to: c2).font(.system(size: 34, weight: .medium).monospacedDigit())
 86                    }
 87                    Spacer()
 88                    VStack(alignment: .trailing, spacing: 0) {
 89                        Text("Lasts").font(.system(size: 12)).opacity(0.7)
 90                        Text(timerInterval: c2...c3, countsDown: true).font(.system(size: 34, weight: .medium).monospacedDigit())
 91                            .foregroundStyle(accent).multilineTextAlignment(.trailing)
 92                    }
 93                } else {
 94                    VStack(alignment: .leading, spacing: 0) {
 95                        Text("Maximum in").font(.system(size: 12)).opacity(0.7)
 96                        countdown(to: a.peak).font(.system(size: 34, weight: .medium).monospacedDigit())
 97                    }
 98                }
 99            }
100            ProgressView(timerInterval: a.c1...a.c4, countsDown: false, label: { EmptyView() }, currentValueLabel: { EmptyView() })
101                .tint(ink)
102            HStack {
103                ForEach(contacts(a), id: \.0) { name, date in
104                    VStack(alignment: .leading, spacing: 0) {
105                        Text(name).font(.system(size: 10)).opacity(0.6)
106                        Text(clock(date, a)).font(.system(size: 11).monospacedDigit())
107                    }
108                    if name != "C4" { Spacer(minLength: 4) }
109                }
110            }
111            Text(a.timeZone == nil ? "Times in UTC" : "Local time at the site").font(.system(size: 10)).opacity(0.6)
112        }
113        .padding(16)
114    }
115}
116
117/// Counts down to `date` and then stays at zero.
118private func countdown(to date: Date) -> Text {
119    Text(timerInterval: date.addingTimeInterval(-86_400)...date, countsDown: true)
120}
121
122private func contacts(_ a: EclipseActivityAttributes) -> [(String, Date)] {
123    [("C1", a.c1), ("C2", a.c2), ("Max", a.c2 == nil ? a.peak : nil), ("C3", a.c3), ("C4", a.c4)].compactMap { n, d in d.map { (n, $0) } }
124}
125
126/// A string: Text(_:format:) would replace the style's time zone with the environment's.
127private func clock(_ date: Date, _ a: EclipseActivityAttributes) -> String {
128    date.formatted(Date.FormatStyle(date: .omitted, time: .standard, timeZone: a.timeZone.flatMap(TimeZone.init(identifier:)) ?? .gmt))
129}