Commit 2da0dd0b68
Verified · cmc
Layout: unified · split
App/Shared/PassActivity.swift added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | import ActivityKit | |
| 2 | import Foundation | |
| 3 | ||
| 4 | /// Live Activity for a satellite pass. Computed on the device and never updated: the view runs on timers | |
| 5 | /// and goes stale after set. Shared by the app and the widget extension. | |
| 6 | struct PassActivityAttributes: ActivityAttributes { | |
| 7 | struct ContentState: Codable, Hashable {} | |
| 8 | ||
| 9 | var name: String | |
| 10 | var rise: Date | |
| 11 | var peak: Date | |
| 12 | var set: Date | |
| 13 | var riseAzimuth: Double | |
| 14 | var setAzimuth: Double | |
| 15 | var maxAltitude: Double | |
| 16 | var visible: Bool | |
| 17 | } | |
App/Sources/PassFollowButton.swift added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | import ActivityKit | |
| 2 | import SkyEngine | |
| 3 | import SwiftUI | |
| 4 | ||
| 5 | /// Bell on a pass row: puts the pass on the Lock Screen from 10 minutes before rise. Hidden for passes more | |
| 6 | /// than a day away. | |
| 7 | struct PassFollowButton: View { | |
| 8 | var pass: SatellitePass | |
| 9 | @State private var following = false | |
| 10 | @State private var message: String? | |
| 11 | ||
| 12 | var body: some View { | |
| 13 | if pass.activityStart(now: .now) != nil { | |
| 14 | Button { | |
| 15 | following ? unfollow() : follow() | |
| 16 | } label: { | |
| 17 | Image(systemName: following ? "bell.fill" : "bell") | |
| 18 | .font(.subheadline) | |
| 19 | .frame(minWidth: 44, minHeight: 44) | |
| 20 | .contentShape(Rectangle()) | |
| 21 | } | |
| 22 | .buttonStyle(.plain) | |
| 23 | .accessibilityLabel(following ? "Stop following on Lock Screen" : "Follow on Lock Screen") | |
| 24 | .onAppear { following = Activity<PassActivityAttributes>.activities.contains { matches($0.attributes, pass) } } | |
| 25 | .alert(message ?? "", isPresented: Binding { message != nil } set: { if !$0 { message = nil } }) {} | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | private func follow() { | |
| 30 | guard ActivityAuthorizationInfo().areActivitiesEnabled else { | |
| 31 | message = "Live Activities are turned off for Moonrise in Settings." | |
| 32 | return | |
| 33 | } | |
| 34 | guard let start = pass.activityStart(now: .now) else { return } | |
| 35 | let attributes = PassActivityAttributes(name: pass.displayName, rise: pass.rise, peak: pass.peak, set: pass.set, | |
| 36 | riseAzimuth: pass.riseAzimuth, setAzimuth: pass.setAzimuth, | |
| 37 | maxAltitude: pass.maxAltitude, visible: pass.visible) | |
| 38 | // It cannot end itself without an update, so it goes stale a minute after set. | |
| 39 | let content = ActivityContent(state: PassActivityAttributes.ContentState(), staleDate: pass.set.addingTimeInterval(60)) | |
| 40 | do { | |
| 41 | if start > .now { | |
| 42 | let rise = pass.rise.formatted(date: .omitted, time: .shortened) | |
| 43 | let alert = AlertConfiguration( | |
| 44 | title: "\(pass.displayName) pass in 10 minutes", | |
| 45 | body: "Rises \(SkyEngine.compass(pass.riseAzimuth)) at \(rise), highest \(Int(pass.maxAltitude.rounded()))°, sets \(SkyEngine.compass(pass.setAzimuth)).", | |
| 46 | sound: .default) | |
| 47 | _ = try Activity.request(attributes: attributes, content: content, style: .standard, alertConfiguration: alert, start: start) | |
| 48 | } else { | |
| 49 | _ = try Activity.request(attributes: attributes, content: content) | |
| 50 | } | |
| 51 | following = true | |
| 52 | } catch { | |
| 53 | message = "Couldn't add the pass: \(error.localizedDescription)" | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | private func unfollow() { | |
| 58 | let pass = pass | |
| 59 | Task { | |
| 60 | for a in Activity<PassActivityAttributes>.activities where matches(a.attributes, pass) { | |
| 61 | await a.end(nil, dismissalPolicy: .immediate) | |
| 62 | } | |
| 63 | following = false | |
| 64 | } | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | /// Rise times shift by a 20 s step each time passes are recomputed. | |
| 69 | private func matches(_ attributes: PassActivityAttributes, _ pass: SatellitePass) -> Bool { | |
| 70 | attributes.name == pass.displayName && abs(attributes.rise.timeIntervalSince(pass.rise)) < 120 | |
| 71 | } | |
App/Sources/TodaySheet.swift +3
| @@ -99,6 +99,9 @@ struct TodaySheet: View { | ||
| 99 | 99 | .font(.footnote.monospacedDigit()).dimmed(0.8) |
| 100 | 100 | } |
| 101 | 101 | .accessibilityElement(children: .combine) |
| 102 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 103 | .padding(.trailing, 44) | |
| 104 | .overlay(alignment: .trailing) { PassFollowButton(pass: p) } | |
| 102 | 105 | } |
| 103 | 106 | let others = alsoVisible |
| 104 | 107 | if !others.isEmpty { |
App/Widgets/ActivityLayout.swift added +36
| @@ -0,0 +1,36 @@ | ||
| 1 | import SwiftUI | |
| 2 | import WidgetKit | |
| 3 | ||
| 4 | /// `full` in a normal Dynamic Island, `limited` where it is narrow: landscape, iPhone Duo, or several | |
| 5 | /// activities at once (iOS 27). | |
| 6 | struct IslandWidth<Full: View, Limited: View>: View { | |
| 7 | @ViewBuilder var full: Full | |
| 8 | @ViewBuilder var limited: Limited | |
| 9 | ||
| 10 | var body: some View { | |
| 11 | if #available(iOS 27, *) { | |
| 12 | LimitedWidthReader(full: full, limited: limited) | |
| 13 | } else { | |
| 14 | full | |
| 15 | } | |
| 16 | } | |
| 17 | } | |
| 18 | ||
| 19 | @available(iOS 27, *) | |
| 20 | private struct LimitedWidthReader<Full: View, Limited: View>: View { | |
| 21 | @Environment(\.isDynamicIslandLimitedInWidth) private var isLimited | |
| 22 | var full: Full | |
| 23 | var limited: Limited | |
| 24 | ||
| 25 | var body: some View { | |
| 26 | if isLimited { limited } else { full } | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 30 | /// Counts down to `date`, or up from it once passed. Unlike `Text(timerInterval:)` it takes the width of its | |
| 31 | /// digits, not the widest the timer could get. | |
| 32 | func liveTimer(_ date: Date) -> Text { | |
| 33 | date > .now | |
| 34 | ? Text(.currentDate, format: .timer(countingDownIn: Date.now..<date)) | |
| 35 | : Text(.currentDate, format: .stopwatch(startingAt: date, maxPrecision: .seconds(1))) | |
| 36 | } | |
App/Widgets/EventLiveActivity.swift +57 −20
| @@ -7,25 +7,8 @@ struct EventLiveActivity: Widget { | ||
| 7 | 7 | |
| 8 | 8 | var body: some WidgetConfiguration { |
| 9 | 9 | ActivityConfiguration(for: EventActivityAttributes.self) { context in |
| 10 | VStack(alignment: .leading, spacing: 4) { | |
| 11 | HStack { | |
| 12 | Text(context.attributes.title).font(.system(size: 17, weight: .medium)) | |
| 13 | Spacer() | |
| 14 | Text(context.attributes.launch, style: .timer).font(.system(size: 17, weight: .medium).monospacedDigit()) | |
| 15 | .multilineTextAlignment(.trailing) | |
| 16 | } | |
| 17 | Text(context.state.phase).font(.system(size: 14)) | |
| 18 | HStack { | |
| 19 | Text("\(Int(context.state.distanceKm).formatted()) km from Earth").font(.system(size: 12)).opacity(0.8) | |
| 20 | Spacer() | |
| 21 | if let next = context.state.next, let at = context.state.nextAt { | |
| 22 | Text("\(next) \(at, style: .relative)").font(.system(size: 12)).opacity(0.8).lineLimit(1) | |
| 23 | } | |
| 24 | } | |
| 25 | } | |
| 26 | .padding(16) | |
| 27 | .foregroundStyle(ink) | |
| 28 | .activityBackgroundTint(Color(red: 0.043, green: 0.078, blue: 0.141)) | |
| 10 | EventActivityView(context: context) | |
| 11 | .foregroundStyle(ink) | |
| 29 | 12 | } dynamicIsland: { context in |
| 30 | 13 | DynamicIsland { |
| 31 | 14 | DynamicIslandExpandedRegion(.leading) { |
| @@ -40,10 +23,64 @@ struct EventLiveActivity: Widget { | ||
| 40 | 23 | } compactLeading: { |
| 41 | 24 | Image(systemName: "moon.stars") |
| 42 | 25 | } compactTrailing: { |
| 43 | Text(context.attributes.launch, style: .timer).font(.system(size: 12).monospacedDigit()).frame(maxWidth: 64) | |
| 26 | IslandWidth { | |
| 27 | liveTimer(context.attributes.launch).font(.system(size: 12).monospacedDigit()) | |
| 28 | } limited: { | |
| 29 | // Mission time in days once past the first day; the full timer does not fit. | |
| 30 | if context.attributes.launch < .now.addingTimeInterval(-86_400) { | |
| 31 | Text("\(Int(-context.attributes.launch.timeIntervalSinceNow / 86_400)) d").font(.system(size: 12).monospacedDigit()) | |
| 32 | } else { | |
| 33 | liveTimer(context.attributes.launch).font(.system(size: 12).monospacedDigit()) | |
| 34 | } | |
| 35 | } | |
| 44 | 36 | } minimal: { |
| 45 | 37 | Image(systemName: "moon.stars") |
| 46 | 38 | } |
| 47 | 39 | } |
| 40 | .supplementalActivityFamilies([.small]) | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | private struct EventActivityView: View { | |
| 45 | @Environment(\.activityFamily) private var family | |
| 46 | @Environment(\.isActivityFullscreen) private var fullscreen | |
| 47 | var context: ActivityViewContext<EventActivityAttributes> | |
| 48 | ||
| 49 | var body: some View { | |
| 50 | Group { | |
| 51 | if family == .small { small } else { medium } | |
| 52 | } | |
| 53 | // StandBy: black, to blend with the clock face. | |
| 54 | .activityBackgroundTint(fullscreen ? .black : Color(red: 0.043, green: 0.078, blue: 0.141)) | |
| 55 | } | |
| 56 | ||
| 57 | /// Apple Watch Smart Stack and CarPlay. | |
| 58 | private var small: some View { | |
| 59 | VStack(alignment: .leading, spacing: 2) { | |
| 60 | Text(context.attributes.title).font(.system(size: 13, weight: .medium)).lineLimit(1) | |
| 61 | liveTimer(context.attributes.launch).font(.system(size: 17, weight: .medium).monospacedDigit()) | |
| 62 | } | |
| 63 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 64 | .padding(12) | |
| 65 | } | |
| 66 | ||
| 67 | private var medium: some View { | |
| 68 | VStack(alignment: .leading, spacing: 4) { | |
| 69 | HStack { | |
| 70 | Text(context.attributes.title).font(.system(size: 17, weight: .medium)) | |
| 71 | Spacer() | |
| 72 | Text(context.attributes.launch, style: .timer).font(.system(size: 17, weight: .medium).monospacedDigit()) | |
| 73 | .multilineTextAlignment(.trailing) | |
| 74 | } | |
| 75 | Text(context.state.phase).font(.system(size: 14)) | |
| 76 | HStack { | |
| 77 | Text("\(Int(context.state.distanceKm).formatted()) km from Earth").font(.system(size: 12)).opacity(0.8) | |
| 78 | Spacer() | |
| 79 | if let next = context.state.next, let at = context.state.nextAt { | |
| 80 | Text("\(next) \(at, style: .relative)").font(.system(size: 12)).opacity(0.8).lineLimit(1) | |
| 81 | } | |
| 82 | } | |
| 83 | } | |
| 84 | .padding(16) | |
| 48 | 85 | } |
| 49 | 86 | } |
App/Widgets/LaunchLiveActivity.swift +72 −25
| @@ -8,26 +8,8 @@ struct LaunchLiveActivity: Widget { | ||
| 8 | 8 | |
| 9 | 9 | var body: some WidgetConfiguration { |
| 10 | 10 | ActivityConfiguration(for: LaunchActivityAttributes.self) { context in |
| 11 | HStack(alignment: .center, spacing: 12) { | |
| 12 | VStack(alignment: .leading, spacing: 2) { | |
| 13 | Text(context.attributes.rocket).font(.system(size: 17, weight: .medium)) | |
| 14 | Text(context.attributes.mission).font(.system(size: 13)).lineLimit(1).opacity(0.8) | |
| 15 | Text(context.attributes.location).font(.system(size: 11)).lineLimit(1).opacity(0.6) | |
| 16 | } | |
| 17 | Spacer() | |
| 18 | VStack(alignment: .trailing, spacing: 2) { | |
| 19 | Text(context.state.status.uppercased()).font(.system(size: 10, weight: .medium)).tracking(0.6) | |
| 20 | .foregroundStyle(context.state.status == "Hold" ? accent : ink) | |
| 21 | HStack(spacing: 2) { | |
| 22 | Text(context.state.net > .now ? "T−" : "T+") | |
| 23 | countdown(context.state.net) | |
| 24 | } | |
| 25 | .font(.system(size: 22, weight: .medium).monospacedDigit()) | |
| 26 | } | |
| 27 | } | |
| 28 | .padding(16) | |
| 29 | .foregroundStyle(ink) | |
| 30 | .activityBackgroundTint(Color(red: 0.043, green: 0.078, blue: 0.141)) | |
| 11 | LaunchActivityView(context: context, ink: ink, accent: accent) | |
| 12 | .foregroundStyle(ink) | |
| 31 | 13 | } dynamicIsland: { context in |
| 32 | 14 | DynamicIsland { |
| 33 | 15 | DynamicIslandExpandedRegion(.leading) { |
| @@ -40,17 +22,82 @@ struct LaunchLiveActivity: Widget { | ||
| 40 | 22 | Text(context.attributes.mission).font(.system(size: 12)).lineLimit(1).opacity(0.8) |
| 41 | 23 | } |
| 42 | 24 | } compactLeading: { |
| 43 | Text("T−").font(.system(size: 12, weight: .medium)) | |
| 25 | IslandWidth { | |
| 26 | Text("T−").font(.system(size: 12, weight: .medium)) | |
| 27 | } limited: { | |
| 28 | Image(systemName: "arrow.up").foregroundStyle(context.state.status == "Hold" ? accent : ink) | |
| 29 | } | |
| 44 | 30 | } compactTrailing: { |
| 45 | countdown(context.state.net).font(.system(size: 12).monospacedDigit()).frame(maxWidth: 64) | |
| 31 | IslandWidth { | |
| 32 | liveTimer(context.state.net).font(.system(size: 12).monospacedDigit()) | |
| 33 | } limited: { | |
| 34 | if context.state.status == "Hold" { | |
| 35 | Text("HOLD").font(.system(size: 12, weight: .medium)).foregroundStyle(accent) | |
| 36 | } else { | |
| 37 | liveTimer(context.state.net).font(.system(size: 12).monospacedDigit()) | |
| 38 | } | |
| 39 | } | |
| 46 | 40 | } minimal: { |
| 47 | 41 | Image(systemName: "arrow.up") |
| 48 | 42 | } |
| 49 | 43 | } |
| 44 | .supplementalActivityFamilies([.small]) | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | private struct LaunchActivityView: View { | |
| 49 | @Environment(\.activityFamily) private var family | |
| 50 | @Environment(\.isActivityFullscreen) private var fullscreen | |
| 51 | var context: ActivityViewContext<LaunchActivityAttributes> | |
| 52 | var ink: Color | |
| 53 | var accent: Color | |
| 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(context.attributes.rocket).font(.system(size: 13, weight: .medium)).lineLimit(1) | |
| 67 | HStack(spacing: 2) { | |
| 68 | Text(context.state.net > .now ? "T−" : "T+") | |
| 69 | liveTimer(context.state.net) | |
| 70 | } | |
| 71 | .font(.system(size: 17, weight: .medium).monospacedDigit()) | |
| 72 | .foregroundStyle(context.state.status == "Hold" ? accent : ink) | |
| 73 | } | |
| 74 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 75 | .padding(12) | |
| 50 | 76 | } |
| 51 | 77 | |
| 52 | /// Counts down to T-0, then shows elapsed time. | |
| 53 | private func countdown(_ net: Date) -> Text { | |
| 54 | net > .now ? Text(timerInterval: Date.now...net, countsDown: true) : Text(net, style: .timer) | |
| 78 | private var medium: some View { | |
| 79 | HStack(alignment: .center, spacing: 12) { | |
| 80 | VStack(alignment: .leading, spacing: 2) { | |
| 81 | Text(context.attributes.rocket).font(.system(size: 17, weight: .medium)) | |
| 82 | Text(context.attributes.mission).font(.system(size: 13)).lineLimit(1).opacity(0.8) | |
| 83 | Text(context.attributes.location).font(.system(size: 11)).lineLimit(1).opacity(0.6) | |
| 84 | } | |
| 85 | Spacer() | |
| 86 | VStack(alignment: .trailing, spacing: 2) { | |
| 87 | Text(context.state.status.uppercased()).font(.system(size: 10, weight: .medium)).tracking(0.6) | |
| 88 | .foregroundStyle(context.state.status == "Hold" ? accent : ink) | |
| 89 | HStack(spacing: 2) { | |
| 90 | Text(context.state.net > .now ? "T−" : "T+") | |
| 91 | countdown(context.state.net) | |
| 92 | } | |
| 93 | .font(.system(size: 22, weight: .medium).monospacedDigit()) | |
| 94 | } | |
| 95 | } | |
| 96 | .padding(16) | |
| 55 | 97 | } |
| 56 | 98 | } |
| 99 | ||
| 100 | /// Counts down to T-0, then shows elapsed time. | |
| 101 | private func countdown(_ net: Date) -> Text { | |
| 102 | net > .now ? Text(timerInterval: Date.now...net, countsDown: true) : Text(net, style: .timer) | |
| 103 | } | |
App/Widgets/MoonriseWidgets.swift +1
| @@ -12,6 +12,7 @@ struct MoonriseWidgets: WidgetBundle { | ||
| 12 | 12 | #if os(iOS) |
| 13 | 13 | LaunchLiveActivity() |
| 14 | 14 | EventLiveActivity() |
| 15 | PassLiveActivity() | |
| 15 | 16 | #endif |
| 16 | 17 | } |
| 17 | 18 | } |
App/Widgets/PassLiveActivity.swift added +128
| @@ -0,0 +1,128 @@ | ||
| 1 | import ActivityKit | |
| 2 | import SkyEngine | |
| 3 | import SwiftUI | |
| 4 | import WidgetKit | |
| 5 | ||
| 6 | struct 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 | ||
| 47 | private 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. | |
| 115 | private 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 | ||
| 124 | private 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 | ||
| 128 | private func time(_ date: Date) -> String { date.formatted(date: .omitted, time: .shortened) } | |
Packages/MoonriseKit/Sources/SkyEngine/Passes.swift +12
| @@ -24,6 +24,13 @@ public struct SatellitePass: Sendable, Identifiable, Equatable { | ||
| 24 | 24 | default: name.replacingOccurrences(of: " R/B", with: " rocket body") |
| 25 | 25 | } |
| 26 | 26 | } |
| 27 | ||
| 28 | /// When a Lock Screen activity for this pass starts: 10 minutes before rise, or `now` if that has passed. | |
| 29 | /// Nil once the pass has set, or when rise is more than a day away and the elements will have drifted. | |
| 30 | public func activityStart(now: Date) -> Date? { | |
| 31 | guard set > now, rise.timeIntervalSince(now) <= 86_400 else { return nil } | |
| 32 | return max(now, rise.addingTimeInterval(-600)) | |
| 33 | } | |
| 27 | 34 | } |
| 28 | 35 | |
| 29 | 36 | extension OrbitalElements { |
| @@ -33,6 +40,11 @@ extension OrbitalElements { | ||
| 33 | 40 | } |
| 34 | 41 | |
| 35 | 42 | extension SkyEngine { |
| 43 | /// Eight-point compass direction for an azimuth in degrees. | |
| 44 | public static func compass(_ azimuth: Double) -> String { | |
| 45 | ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Int(((azimuth + 22.5) / 45).rounded(.down)) % 8] | |
| 46 | } | |
| 47 | ||
| 36 | 48 | /// Greenwich mean sidereal time in radians (IAU 1982, as used with SGP4's TEME frame). |
| 37 | 49 | static func gmst(_ date: Date) -> Double { |
| 38 | 50 | let jd = date.timeIntervalSince1970 / 86_400 + 2_440_587.5 |
Packages/MoonriseKit/Tests/SkyEngineTests/PassActivityTests.swift added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import SkyEngine | |
| 4 | ||
| 5 | private let now = Date(timeIntervalSince1970: 1_792_364_400) | |
| 6 | ||
| 7 | private func pass(risingIn seconds: TimeInterval) -> SatellitePass { | |
| 8 | SatellitePass(name: "ISS (ZARYA)", rise: now.addingTimeInterval(seconds), set: now.addingTimeInterval(seconds + 360), peak: now.addingTimeInterval(seconds + 180), | |
| 9 | maxAltitude: 62, riseAzimuth: 240, setAzimuth: 60, visible: true, featured: true) | |
| 10 | } | |
| 11 | ||
| 12 | @Test func activityStartsTenMinutesBeforeRise() { | |
| 13 | #expect(pass(risingIn: 3600).activityStart(now: now) == now.addingTimeInterval(3000)) | |
| 14 | #expect(pass(risingIn: 24 * 3600).activityStart(now: now) == now.addingTimeInterval(24 * 3600 - 600)) | |
| 15 | } | |
| 16 | ||
| 17 | @Test func activityStartsNowWithinTenMinutesOrDuringThePass() { | |
| 18 | #expect(pass(risingIn: 300).activityStart(now: now) == now) | |
| 19 | #expect(pass(risingIn: -120).activityStart(now: now) == now) | |
| 20 | } | |
| 21 | ||
| 22 | @Test func noActivityAfterSetOrBeyondADay() { | |
| 23 | #expect(pass(risingIn: -360).activityStart(now: now) == nil) | |
| 24 | #expect(pass(risingIn: 24 * 3600 + 1).activityStart(now: now) == nil) | |
| 25 | } | |
| 26 | ||
| 27 | @Test func compassPoints() { | |
| 28 | #expect(SkyEngine.compass(0) == "N") | |
| 29 | #expect(SkyEngine.compass(22.4) == "N") | |
| 30 | #expect(SkyEngine.compass(22.5) == "NE") | |
| 31 | #expect(SkyEngine.compass(240) == "SW") | |
| 32 | #expect(SkyEngine.compass(359) == "N") | |
| 33 | } | |