App/Widgets/EclipseLiveActivity.swift
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}