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/Sources/NewsList.swift

main
moonrise/App/Sources/NewsList.swift history · blame · raw

63 lines · 2538 bytes

3 symbols in this file
 1import Feeds
 2import SwiftUI
 3
 4/// Latest space news headlines; tapping one opens the article in Safari.
 5struct NewsList: View {
 6    var ink: Color
 7    var accent: Color
 8    var reload = 0
 9    @State private var articles: [NewsArticle]?
10    @State private var failed = false
11    @Environment(\.openURL) private var openURL
12
13    var body: some View {
14        VStack(alignment: .leading, spacing: 0) {
15            if let articles {
16                ForEach(articles) { a in
17                    Button { openURL(a.url) } label: { row(a) }
18                        .buttonStyle(.plain)
19                        .accessibilityHint("Opens the article in Safari")
20                }
21                Text("Headlines: Spaceflight News API, The Space Devs. Articles belong to their publishers.")
22                    .font(.caption2).dimmed(0.5).padding(.vertical, 16)
23            } else if failed {
24                Text("Couldn't load news. Pull to retry.").font(.footnote).foregroundStyle(accent).padding(.vertical, 12)
25            } else {
26                ProgressView().tint(ink).padding(.top, 40).frame(maxWidth: .infinity)
27            }
28        }
29        .task(id: reload) { await load(force: reload > 0) }
30    }
31
32    /// The API caches for 10 minutes; reuse a fetch from that window unless pulled to refresh.
33    func load(force: Bool) async {
34        if !force, let cached: [NewsArticle] = FeedMemo.get("news", maxAge: 600) { articles = cached; return }
35        do {
36            articles = try await NewsArticle.latest()
37            if let articles { FeedMemo.set("news", articles) }
38            failed = false
39        } catch {
40            failed = articles == nil
41        }
42    }
43
44    private func row(_ a: NewsArticle) -> some View {
45        HStack(alignment: .top, spacing: 12) {
46            VStack(alignment: .leading, spacing: 6) {
47                Text("\(a.site) · \(a.published.formatted(.relative(presentation: .named)))").font(.caption2).dimmed(0.7)
48                Text(a.title).font(.headline)
49                Text(a.summary).font(.footnote).dimmed(0.8).lineLimit(3)
50            }
51            Spacer(minLength: 0)
52            if let image = a.image {
53                RemoteImage(url: image, points: 110) { ink.opacity(0.06) }
54                .frame(width: 72, height: 72)
55                .clipped()
56                .accessibilityHidden(true)
57            }
58        }
59        .padding(.vertical, 14)
60        .overlay(alignment: .top) { Rectangle().fill(ink.opacity(0.25)).frame(height: 0.5) }
61        .contentShape(Rectangle())
62    }
63}