App/Widgets/PassLiveActivity.swift
128 lines · 5650 bytes
6 symbols in this file
1import ActivityKit
2import SkyEngine
3import SwiftUI
4import WidgetKit
5
6struct PassLiveActivity: Widget {
7 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
8
9 var body: some WidgetConfiguration {
10 ActivityConfiguration(for: PassActivityAttributes.self) { context in
11 PassActivityView(context: context, ink: ink)
12 .foregroundStyle(ink)
13 } dynamicIsland: { context in
14 DynamicIsland {
15 DynamicIslandExpandedRegion(.leading) {
16 Text(context.attributes.name).font(.system(size: 14, weight: .medium)).lineLimit(1)
17 }
18 DynamicIslandExpandedRegion(.trailing) {
19 liveTimer(context.attributes.rise).font(.system(size: 14, weight: .medium).monospacedDigit())
20 }
21 DynamicIslandExpandedRegion(.bottom) {
22 if context.isStale {
23 Text("Passed \(time(context.attributes.set))").font(.system(size: 12)).opacity(0.8)
24 } else {
25 VStack(alignment: .leading, spacing: 6) {
26 Text(summary(context.attributes)).font(.system(size: 12).monospacedDigit()).lineLimit(1).opacity(0.8)
27 passProgress(context.attributes, ink: ink)
28 }
29 }
30 }
31 } compactLeading: {
32 IslandWidth {
33 Text(context.attributes.name).font(.system(size: 12, weight: .medium)).lineLimit(1)
34 } limited: {
35 Image(systemName: "sparkle")
36 }
37 } compactTrailing: {
38 liveTimer(context.attributes.rise).font(.system(size: 12).monospacedDigit())
39 } minimal: {
40 Image(systemName: "sparkle")
41 }
42 }
43 .supplementalActivityFamilies([.small])
44 }
45}
46
47private struct PassActivityView: View {
48 @Environment(\.activityFamily) private var family
49 @Environment(\.isActivityFullscreen) private var fullscreen
50 var context: ActivityViewContext<PassActivityAttributes>
51 var ink: Color
52
53 private var pass: PassActivityAttributes { context.attributes }
54
55 var body: some View {
56 Group {
57 if family == .small { small } else { medium }
58 }
59 // StandBy: black, to blend with the clock face.
60 .activityBackgroundTint(fullscreen ? .black : Color(red: 0.043, green: 0.078, blue: 0.141))
61 }
62
63 /// Apple Watch Smart Stack and CarPlay.
64 private var small: some View {
65 VStack(alignment: .leading, spacing: 2) {
66 Text("\(pass.name) \(Int(pass.maxAltitude.rounded()))°").font(.system(size: 13, weight: .medium)).lineLimit(1)
67 if context.isStale {
68 Text("Passed \(time(pass.set))").font(.system(size: 13).monospacedDigit()).opacity(0.8)
69 } else {
70 liveTimer(pass.rise).font(.system(size: 17, weight: .medium).monospacedDigit())
71 }
72 }
73 .frame(maxWidth: .infinity, alignment: .leading)
74 .padding(12)
75 }
76
77 private var medium: some View {
78 VStack(alignment: .leading, spacing: 8) {
79 HStack(alignment: .firstTextBaseline) {
80 Text("\(pass.name) pass").font(.system(size: 10, weight: .medium)).textCase(.uppercase).tracking(0.6).opacity(0.8)
81 if pass.visible {
82 Text("Visible").font(.system(size: 10, weight: .medium)).textCase(.uppercase).tracking(0.6)
83 }
84 Spacer()
85 if !context.isStale {
86 liveTimer(pass.rise).font(.system(size: 17, weight: .medium).monospacedDigit())
87 }
88 }
89 Rectangle().fill(ink.opacity(0.4)).frame(height: 0.5)
90 if context.isStale {
91 Text("Passed \(time(pass.set))").font(.system(size: 17, weight: .medium).monospacedDigit())
92 } else {
93 HStack(alignment: .top) {
94 column("Rise", time(pass.rise), SkyEngine.compass(pass.riseAzimuth))
95 Spacer()
96 column("Highest", time(pass.peak), "\(Int(pass.maxAltitude.rounded()))°")
97 Spacer()
98 column("Set", time(pass.set), SkyEngine.compass(pass.setAzimuth))
99 }
100 passProgress(pass, ink: ink)
101 }
102 }
103 .padding(16)
104 }
105
106 private func column(_ label: String, _ value: String, _ detail: String) -> some View {
107 VStack(alignment: .leading, spacing: 1) {
108 Text(label).font(.system(size: 10, weight: .medium)).textCase(.uppercase).tracking(0.6).opacity(0.6)
109 Text("\(value) \(detail)").font(.system(size: 15, weight: .medium).monospacedDigit())
110 }
111 }
112}
113
114/// Empty before rise, full at set, with the time left in the pass beside it.
115private func passProgress(_ pass: PassActivityAttributes, ink: Color) -> some View {
116 HStack(spacing: 8) {
117 ProgressView(timerInterval: pass.rise...pass.set, countsDown: false) { EmptyView() } currentValueLabel: { EmptyView() }
118 .tint(ink)
119 Text(.currentDate, format: .timer(countingDownIn: pass.rise..<pass.set))
120 .font(.system(size: 12).monospacedDigit()).opacity(0.8)
121 }
122}
123
124private func summary(_ pass: PassActivityAttributes) -> String {
125 "Rises \(SkyEngine.compass(pass.riseAzimuth)) \(time(pass.rise)), highest \(Int(pass.maxAltitude.rounded()))°, sets \(SkyEngine.compass(pass.setAzimuth))"
126}
127
128private func time(_ date: Date) -> String { date.formatted(date: .omitted, time: .shortened) }