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

623ba8919c3beb07ea7cc2bd76bb989ab0e93c25
moonrise/App/Widgets/EclipseLiveActivity.swift history · blame · raw

94 lines · 4773 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            let a = context.attributes
12            VStack(alignment: .leading, spacing: 6) {
13                Text("\(a.kind) eclipse · \(a.site)").font(.system(size: 15, weight: .medium))
14                HStack(alignment: .firstTextBaseline) {
15                    if let c2 = a.c2, let c3 = a.c3 {
16                        VStack(alignment: .leading, spacing: 0) {
17                            Text(a.kind == "Total" ? "Totality in" : "Annularity in").font(.system(size: 12)).opacity(0.7)
18                            countdown(to: c2).font(.system(size: 34, weight: .medium).monospacedDigit())
19                        }
20                        Spacer()
21                        VStack(alignment: .trailing, spacing: 0) {
22                            Text("Lasts").font(.system(size: 12)).opacity(0.7)
23                            Text(timerInterval: c2...c3, countsDown: true).font(.system(size: 34, weight: .medium).monospacedDigit())
24                                .foregroundStyle(accent).multilineTextAlignment(.trailing)
25                        }
26                    } else {
27                        VStack(alignment: .leading, spacing: 0) {
28                            Text("Maximum in").font(.system(size: 12)).opacity(0.7)
29                            countdown(to: a.peak).font(.system(size: 34, weight: .medium).monospacedDigit())
30                        }
31                    }
32                }
33                ProgressView(timerInterval: a.c1...a.c4, countsDown: false, label: { EmptyView() }, currentValueLabel: { EmptyView() })
34                    .tint(ink)
35                HStack {
36                    ForEach(contacts(a), id: \.0) { name, date in
37                        VStack(alignment: .leading, spacing: 0) {
38                            Text(name).font(.system(size: 10)).opacity(0.6)
39                            Text(clock(date, a)).font(.system(size: 11).monospacedDigit())
40                        }
41                        if name != "C4" { Spacer(minLength: 4) }
42                    }
43                }
44                Text(a.timeZone == nil ? "Times in UTC" : "Local time at the site").font(.system(size: 10)).opacity(0.6)
45            }
46            .padding(16)
47            .foregroundStyle(ink)
48            .activityBackgroundTint(.black)
49        } dynamicIsland: { context in
50            let a = context.attributes
51            let next = a.c2 ?? a.peak
52            return DynamicIsland {
53                DynamicIslandExpandedRegion(.leading) {
54                    Text("\(a.kind) eclipse").font(.system(size: 14, weight: .medium))
55                }
56                DynamicIslandExpandedRegion(.trailing) {
57                    countdown(to: next).font(.system(size: 14).monospacedDigit())
58                }
59                DynamicIslandExpandedRegion(.bottom) {
60                    if let c2 = a.c2, let c3 = a.c3 {
61                        HStack {
62                            Text(a.site)
63                            Spacer()
64                            Text("\(clock(c2, a))–\(clock(c3, a))")
65                        }
66                        .font(.system(size: 12).monospacedDigit()).opacity(0.8)
67                    } else {
68                        Text("\(a.site) · maximum \(clock(a.peak, a))").font(.system(size: 12).monospacedDigit()).opacity(0.8)
69                    }
70                }
71            } compactLeading: {
72                Text(a.c2 == nil ? "Max" : "C2").font(.system(size: 12, weight: .medium))
73            } compactTrailing: {
74                countdown(to: next).font(.system(size: 12).monospacedDigit()).frame(maxWidth: 64)
75            } minimal: {
76                Image(systemName: "sun.max")
77            }
78        }
79    }
80
81    /// Counts down to `date` and then stays at zero.
82    private func countdown(to date: Date) -> Text {
83        Text(timerInterval: date.addingTimeInterval(-86_400)...date, countsDown: true)
84    }
85
86    private func contacts(_ a: EclipseActivityAttributes) -> [(String, Date)] {
87        [("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) } }
88    }
89
90    /// A string: Text(_:format:) would replace the style's time zone with the environment's.
91    private func clock(_ date: Date, _ a: EclipseActivityAttributes) -> String {
92        date.formatted(Date.FormatStyle(date: .omitted, time: .standard, timeZone: a.timeZone.flatMap(TimeZone.init(identifier:)) ?? .gmt))
93    }
94}