App/Sources/MarsSection.swift
90 lines · 4271 bytes
3 symbols in this file
1import Feeds
2import SkyEngine
3import SwiftUI
4
5/// Local mean solar time and sol number at the active Mars rovers, ticking each second, and their latest raw images.
6struct MarsSection: View {
7 @State private var images: [MarsImage] = []
8 @State private var shown: MarsImage?
9
10 var body: some View {
11 VStack(alignment: .leading, spacing: 10) {
12 Text("Mars now").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
13 Text("The time of day where NASA's two rovers are working, and their newest raw photos, usually a few hours old.")
14 .font(.footnote).dimmed(0.75)
15 VStack(alignment: .leading, spacing: 8) {
16 ForEach(MarsTime.Rover.active) { rover in
17 TimelineView(.periodic(from: .now, by: 1)) { ctx in
18 HStack(alignment: .firstTextBaseline) {
19 VStack(alignment: .leading, spacing: 2) {
20 Text(rover.name).font(.subheadline.weight(.medium))
21 Text("\(rover.site) · sol \(MarsTime.sol(ctx.date, rover: rover))").font(.footnote).dimmed(0.7)
22 }
23 Spacer()
24 Text(MarsTime.format(hours: MarsTime.localMeanTime(ctx.date, eastLongitude: rover.longitude)))
25 .font(.title3.monospacedDigit())
26 }
27 .accessibilityElement(children: .combine)
28 }
29 strip(rover.name)
30 }
31 }
32 Text("Local mean solar time. A Mars sol is 24 h 39 m 35 s. Raw images: NASA/JPL-Caltech.").font(.caption2).dimmed(0.5)
33 }
34 .task {
35 // The rovers send a few batches a day; reuse a fetch from the last 30 minutes.
36 if let cached: [MarsImage] = FeedMemo.get("mars", maxAge: 1800) { images = cached; return }
37 if let fresh = try? await MarsImage.latest() { images = fresh; FeedMemo.set("mars", fresh) }
38 }
39 .sheet(item: $shown) { MarsImageView(image: $0) }
40 }
41
42 @ViewBuilder
43 private func strip(_ rover: String) -> some View {
44 let mine = images.filter { $0.rover == rover }
45 if !mine.isEmpty {
46 ScrollView(.horizontal, showsIndicators: false) {
47 HStack(spacing: 6) {
48 ForEach(mine) { image in
49 Button { shown = image } label: {
50 RemoteImage(url: image.thumbnail, points: 130) { Color.white.opacity(0.06) }
51 .frame(width: 88, height: 88)
52 .clipped()
53 }
54 .accessibilityLabel("\(image.camera), sol \(image.sol)")
55 }
56 }
57 }
58 .padding(.bottom, 6)
59 }
60 }
61}
62
63struct MarsImageView: View {
64 var image: MarsImage
65 @Environment(\.dismiss) private var dismiss
66
67 var body: some View {
68 NavigationStack {
69 ScrollView {
70 VStack(alignment: .leading, spacing: 8) {
71 AsyncImage(url: image.image) { phase in
72 switch phase {
73 case .success(let img): img.resizable().scaledToFit()
74 case .failure: Color.black.frame(height: 200).overlay(Text("Image unavailable").font(.caption).dimmed(0.6))
75 default: Color.black.frame(height: 300).overlay(ProgressView())
76 }
77 }
78 Text("\(image.rover) · \(image.camera) · sol \(image.sol)").font(.subheadline.weight(.medium))
79 Text(image.taken.formatted(date: .abbreviated, time: .shortened)).font(.footnote).dimmed(0.7)
80 Text("Raw, unprocessed image. Credit: \(image.credit).").font(.caption2).dimmed(0.55)
81 }
82 .padding(.horizontal, 16)
83 }
84 .background(Color.black.ignoresSafeArea())
85 .navigationBarTitleDisplayMode(.inline)
86 .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } }
87 }
88 .preferredColorScheme(.dark)
89 }
90}