2026-06-18 18:04:59 +03:00
|
|
|
// StoryRowView.swift
|
|
|
|
|
// Jarvis — one row in the signal feed. Everything fades with the signal score.
|
|
|
|
|
|
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
struct StoryRowView: View {
|
|
|
|
|
let story: StorySummary
|
|
|
|
|
/// True when the story's articles are cached in SwiftData (offline-ready).
|
|
|
|
|
var isCached: Bool = false
|
2026-06-18 23:22:56 +03:00
|
|
|
/// True once the story has been opened — drops the orange stripe and dims it.
|
|
|
|
|
var isRead: Bool = false
|
2026-06-19 15:07:58 +03:00
|
|
|
/// Slim single-line presentation, used for the "Read" shelf.
|
|
|
|
|
var compact: Bool = false
|
2026-06-18 18:04:59 +03:00
|
|
|
|
|
|
|
|
private var sourceNames: [String] { story.sources.map(\.name) }
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
2026-06-19 15:07:58 +03:00
|
|
|
if compact { compactBody } else { fullBody }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var compactBody: some View {
|
|
|
|
|
HStack(spacing: 0) {
|
|
|
|
|
SignalStripe(score: story.signalScore, muted: true)
|
|
|
|
|
Text(story.headline)
|
|
|
|
|
.font(.system(size: 14, weight: .bold))
|
|
|
|
|
.foregroundStyle(Color(hex: "6A6A6A"))
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
.padding(.leading, 14)
|
|
|
|
|
.padding(.vertical, 11)
|
|
|
|
|
Spacer(minLength: 12)
|
|
|
|
|
Text("\(story.signalScore)")
|
|
|
|
|
.font(.system(size: 14, weight: .heavy, design: .monospaced))
|
|
|
|
|
.foregroundStyle(Color(hex: "474747"))
|
|
|
|
|
.padding(.trailing, 16)
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
.background(Color.black)
|
|
|
|
|
.overlay(alignment: .bottom) { Rectangle().fill(Palette.hairline).frame(height: 0.5) }
|
|
|
|
|
.contentShape(Rectangle())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var fullBody: some View {
|
2026-06-18 18:04:59 +03:00
|
|
|
HStack(alignment: .top, spacing: 0) {
|
2026-06-21 01:58:51 +03:00
|
|
|
if isRead {
|
|
|
|
|
// No stripe for read stories — remove the orange signal indicator entirely.
|
|
|
|
|
Color.clear.frame(width: 3)
|
|
|
|
|
} else {
|
|
|
|
|
SignalStripe(score: story.signalScore)
|
|
|
|
|
}
|
2026-06-18 18:04:59 +03:00
|
|
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 9) {
|
|
|
|
|
// Title (+ cached dot)
|
|
|
|
|
HStack(alignment: .top, spacing: 8) {
|
|
|
|
|
Text(story.headline)
|
|
|
|
|
.font(.system(size: 17, weight: .heavy))
|
|
|
|
|
.foregroundStyle(Signal.titleColor(story.signalScore))
|
|
|
|
|
.lineLimit(3)
|
|
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
|
|
|
|
|
|
if isCached {
|
|
|
|
|
CachedDot()
|
|
|
|
|
.padding(.top, 6)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 01:00:39 +03:00
|
|
|
// Summary preview — the gist, without opening the story.
|
|
|
|
|
if !story.summary.isEmpty {
|
|
|
|
|
Text(story.summary)
|
|
|
|
|
.font(.system(size: 13, weight: .regular))
|
|
|
|
|
.foregroundStyle(Color(hex: "8A8A8A"))
|
|
|
|
|
.lineLimit(6)
|
|
|
|
|
.lineSpacing(2)
|
|
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 18:04:59 +03:00
|
|
|
// Source chips
|
|
|
|
|
if !sourceNames.isEmpty {
|
|
|
|
|
SourceChips(names: sourceNames, total: story.sourceCount)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Metadata
|
|
|
|
|
Text(metaLine)
|
|
|
|
|
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
|
|
|
|
.foregroundStyle(Color(hex: "666666"))
|
|
|
|
|
}
|
|
|
|
|
.padding(.leading, 14)
|
|
|
|
|
.padding(.vertical, 16)
|
|
|
|
|
|
|
|
|
|
Spacer(minLength: 12)
|
|
|
|
|
|
|
|
|
|
// Signal score column
|
|
|
|
|
Text("\(story.signalScore)")
|
|
|
|
|
.font(.system(size: 22, weight: .heavy, design: .monospaced))
|
|
|
|
|
.foregroundStyle(Signal.scoreColor(story.signalScore))
|
|
|
|
|
.padding(.trailing, 16)
|
|
|
|
|
.padding(.top, 16)
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
.background(Color.black)
|
|
|
|
|
.overlay(alignment: .bottom) {
|
|
|
|
|
Rectangle().fill(Palette.hairline).frame(height: 0.5)
|
|
|
|
|
}
|
|
|
|
|
.contentShape(Rectangle())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var metaLine: String {
|
|
|
|
|
let sources = "\(story.sourceCount) source\(story.sourceCount == 1 ? "" : "s")"
|
|
|
|
|
return "\(sources) · \(Topic.label(story.topic)) · \(story.updatedAt.timeAgoShort()) ago"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#Preview {
|
|
|
|
|
let sources = [
|
|
|
|
|
StorySource(id: "1", name: "Daily Monitor", url: "", publishedAt: Date(), isBreaking: true),
|
|
|
|
|
StorySource(id: "2", name: "NilePost", url: "", publishedAt: Date(), isBreaking: false),
|
|
|
|
|
StorySource(id: "3", name: "The EastAfrican", url: "", publishedAt: Date(), isBreaking: false),
|
|
|
|
|
]
|
|
|
|
|
let breakdown = ScoreBreakdown(sourceAuthority: 28, freshness: 22, localRelevance: 15, crossSourceConfirmation: 16, topicImportance: 10)
|
|
|
|
|
return VStack(spacing: 0) {
|
|
|
|
|
ForEach([97, 64, 33, 14], id: \.self) { score in
|
|
|
|
|
StoryRowView(
|
|
|
|
|
story: StorySummary(
|
|
|
|
|
id: "\(score)", headline: "Bank of Uganda cuts interest rates by 50 basis points",
|
Filter pills by backend tags (slug membership), drop keyword matching
Replace the keyword/source-majority Interest system with a StoryPill enum that
matches stories by canonical category slugs. StorySummary gains a tolerant
tags[] (optional, decode-safe); effectiveTags uses tags, falling back to topic
during the backend transition. No more headline keyword-searching on the client —
the backend decides classification; the client asks slug membership.
Pills: All, F1, Sport, AI, Cloud, HomeLab, Tech, Uganda, South Africa, Canada, US.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:55:57 +03:00
|
|
|
summary: "", topic: "finance", tags: ["finance"], signalScore: score, scoreBreakdown: breakdown,
|
2026-06-18 18:04:59 +03:00
|
|
|
sourceCount: 7, sources: sources, consensus: nil, conflict: nil,
|
|
|
|
|
updatedAt: Date().addingTimeInterval(-3600), createdAt: Date()),
|
|
|
|
|
isCached: score == 64
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.background(Color.black)
|
|
|
|
|
}
|