App/Widgets/EclipseLiveActivity.swift
129 lines · 5735 bytes
5 symbols in this file
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}