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/PassLiveActivity.swift

59d401dca1a327705632d1e2e20f00f807dee828
moonrise/App/Widgets/PassLiveActivity.swift history · blame · raw

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) }