Files
jarvis/Jarvis/Views/Home/StoryRowView.swift

127 lines
5.1 KiB
Swift
Raw Normal View History

// 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
/// True once the story has been opened drops the orange stripe and dims it.
var isRead: Bool = false
/// Slim single-line presentation, used for the "Read" shelf.
var compact: Bool = false
private var sourceNames: [String] { story.sources.map(\.name) }
var body: some View {
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: 15, weight: .semibold, design: .default))
.foregroundStyle(Palette.secondaryText)
.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(Palette.background)
.overlay(alignment: .bottom) { Rectangle().fill(Palette.hairline).frame(height: 0.5) }
.contentShape(Rectangle())
}
private var fullBody: some View {
HStack(alignment: .top, spacing: 0) {
if isRead {
// No stripe for read stories remove the orange signal indicator entirely.
Color.clear.frame(width: 3)
} else {
SignalStripe(score: story.signalScore)
}
VStack(alignment: .leading, spacing: 9) {
Text(story.headline)
.font(.system(size: 19, weight: .bold, design: .default))
.foregroundStyle(Signal.titleColor(story.signalScore))
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
if !story.summary.isEmpty {
Text(story.summary)
.font(.system(size: 15, weight: .regular, design: .default))
.foregroundStyle(Palette.bodyText)
.lineLimit(5)
.lineSpacing(3)
.fixedSize(horizontal: false, vertical: true)
}
if !sourceNames.isEmpty {
SourceChips(names: sourceNames, total: story.sourceCount)
}
Text(metaLine)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(Palette.tertiaryText)
}
.padding(.leading, 14)
.padding(.vertical, 16)
Spacer(minLength: 12)
// Signal cluster: score + offline dot, compact and aligned
VStack(alignment: .trailing, spacing: 4) {
Text("\(story.signalScore)")
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundStyle(Signal.scoreColor(story.signalScore))
if isCached {
CachedDot(size: 6)
}
}
.padding(.trailing, 16)
.padding(.top, 18)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.background)
.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",
summary: "", topic: "finance", tags: ["finance"], signalScore: score, scoreBreakdown: breakdown,
sourceCount: 7, sources: sources, consensus: nil, conflict: nil,
updatedAt: Date().addingTimeInterval(-3600), createdAt: Date()),
isCached: score == 64
)
}
}
.background(Palette.background)
}