// 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: 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 { HStack(alignment: .top, spacing: 0) { SignalStripe(score: story.signalScore, muted: isRead) 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) } } // 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) } // 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) } .opacity(isRead ? 0.55 : 1) .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", 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(Color.black) }