App/Sources/NewsList.swift
63 lines · 2538 bytes
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}