Files
jarvis/Jarvis/Views/Story/StoryDetailView.swift
Robin Kutesa 27d0e22767 Initial commit: Jarvis iOS app
SwiftUI client for a self-hosted RSS news-correlation platform: signal feed,
story detail, article reader, feed manager, and LAN⇄Tailscale connectivity.
Project generated from project.yml via XcodeGen.

Includes CI build matrix (macOS 14/15 × Debug/Release), issue templates,
backlog, and API/backend handoff docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:04:59 +03:00

245 lines
9.5 KiB
Swift

// StoryDetailView.swift
// Jarvis full story: consensus / conflict / coverage timeline.
import SwiftUI
import SwiftData
@MainActor
final class StoryDetailViewModel: ObservableObject {
@Published var detail: StoryDetail?
@Published var isLoading = false
@Published var error: String?
private let api = APIClient.shared
func load(id: String) async {
isLoading = true
error = nil
do {
detail = try await api.fetchStory(id: id)
} catch let e as APIError {
error = e.errorDescription
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
struct StoryDetailView: View {
let story: StorySummary
@Environment(\.dismiss) private var dismiss
@StateObject private var vm = StoryDetailViewModel()
@Query private var cachedArticles: [CachedArticle]
// Prefer freshly-loaded detail; fall back to the summary we navigated with.
private var topic: String { story.topic }
private var score: Int { vm.detail?.signalScore ?? story.signalScore }
private var headline: String { vm.detail?.headline ?? story.headline }
private var summary: String { vm.detail?.summary ?? story.summary }
private var consensus: String? { vm.detail?.consensus ?? story.consensus }
private var conflict: String? { vm.detail?.conflict ?? story.conflict }
private var sourceCount: Int { vm.detail?.sourceCount ?? story.sourceCount }
private var timeline: [TimelineEntry] {
(vm.detail?.timeline ?? []).sorted { $0.publishedAt < $1.publishedAt }
}
/// Unique source names in coverage order.
private var sourceNames: [String] {
var seen = Set<String>(); var out: [String] = []
for e in timeline where !seen.contains(e.source) { seen.insert(e.source); out.append(e.source) }
if out.isEmpty { out = story.sources.map(\.name) }
return out
}
private var cachedIds: Set<String> {
Set(cachedArticles.filter { $0.storyId == story.id }.map(\.id))
}
private var allCached: Bool {
!timeline.isEmpty && timeline.allSatisfy { cachedIds.contains($0.articleId) }
}
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
categoryRow
Text(headline)
.font(.system(size: 28, weight: .heavy))
.foregroundStyle(.white)
.fixedSize(horizontal: false, vertical: true)
if !summary.isEmpty {
Text(summary)
.font(.system(size: 15, weight: .regular))
.foregroundStyle(Color(hex: "9A9A9A"))
.lineSpacing(4)
}
SourceChips(names: sourceNames, total: sourceCount)
if allCached {
Text("All \(timeline.count) articles cached · available offline")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(Color(hex: "6FBF6F"))
}
if let consensus { consensusBlock(consensus) }
if let conflict { conflictBlock(conflict) }
timelineSection
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
}
}
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .topBarLeading) { backButton }
}
.toolbarBackground(Color.black, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.preferredColorScheme(.dark)
.task { await vm.load(id: story.id) }
}
// MARK: - Pieces
private var backButton: some View {
Button { dismiss() } label: {
HStack(spacing: 4) {
Image(systemName: "chevron.left").font(.system(size: 15, weight: .bold))
Text("Signal feed").font(.system(size: 16, weight: .regular))
}
.foregroundStyle(Palette.orange)
}
}
private var categoryRow: some View {
HStack(spacing: 10) {
Text(Topic.label(topic).uppercased())
.font(.system(size: 11, weight: .bold, design: .monospaced))
.kerning(0.8)
.foregroundStyle(Color(hex: "888888"))
Text("·").foregroundStyle(Color(hex: "444444"))
Text("\(score) signal")
.font(.system(size: 11, weight: .bold, design: .monospaced))
.foregroundStyle(Signal.scoreColor(score))
Spacer()
}
.padding(.vertical, 6)
.padding(.horizontal, 12)
.background(Palette.surface)
.clipShape(Capsule())
}
private func consensusBlock(_ text: String) -> some View {
infoBlock(title: "CONSENSUS", text: text,
border: Palette.consensusBorder, fill: Palette.consensusFill,
titleColor: Palette.orange)
}
private func conflictBlock(_ text: String) -> some View {
infoBlock(title: "CONFLICTING REPORTS", text: text,
border: Palette.conflictBorder, fill: Palette.conflictFill,
titleColor: Color(hex: "C25555"))
}
private func infoBlock(title: String, text: String, border: Color, fill: Color, titleColor: Color) -> some View {
HStack(spacing: 0) {
Rectangle().fill(border).frame(width: 3)
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(size: 10, weight: .bold, design: .monospaced))
.kerning(0.8)
.foregroundStyle(titleColor)
Text(text)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(Color(hex: "C8C8C8"))
.lineSpacing(3)
.fixedSize(horizontal: false, vertical: true)
}
.padding(14)
Spacer(minLength: 0)
}
.background(fill)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private var timelineSection: some View {
VStack(alignment: .leading, spacing: 0) {
Text("COVERAGE TIMELINE")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.kerning(0.8)
.foregroundStyle(Color(hex: "555555"))
.padding(.bottom, 14)
if vm.isLoading && timeline.isEmpty {
ProgressView().tint(Palette.orange).padding(.vertical, 20)
} else if timeline.isEmpty {
Text(vm.error ?? "No coverage available.")
.font(.system(size: 13))
.foregroundStyle(Color(hex: "555555"))
} else {
ForEach(Array(timeline.enumerated()), id: \.element.id) { index, entry in
NavigationLink(value: ArticleRoute(articleId: entry.articleId,
storyId: story.id,
parentHeadline: headline)) {
timelineRow(entry, isFirst: index == 0,
isLast: index == timeline.count - 1,
cached: cachedIds.contains(entry.articleId))
}
.buttonStyle(.plain)
}
}
}
.padding(.top, 8)
}
private func timelineRow(_ entry: TimelineEntry, isFirst: Bool, isLast: Bool, cached: Bool) -> some View {
HStack(alignment: .top, spacing: 14) {
// Node + connector
VStack(spacing: 0) {
Circle()
.fill(isFirst ? Palette.orange : Color(hex: "333333"))
.frame(width: 11, height: 11)
.overlay(Circle().stroke(Color.black, lineWidth: 2))
if !isLast {
Rectangle().fill(Color(hex: "222222")).frame(width: 1.5)
}
}
.frame(width: 11)
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 8) {
Text(entry.source)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(isFirst ? Palette.orange : Color(hex: "999999"))
if entry.isBreaking {
Text("BREAKING")
.font(.system(size: 9, weight: .bold, design: .monospaced))
.foregroundStyle(.white)
.padding(.horizontal, 5).padding(.vertical, 2)
.background(Palette.conflictBorder)
.clipShape(Capsule())
}
Spacer()
Text(entry.publishedAt.clockShort)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(Color(hex: "555555"))
if cached { CachedDot(size: 6) }
}
Text(entry.headline)
.font(.system(size: 15, weight: .regular))
.foregroundStyle(Color(hex: "D0D0D0"))
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
}
.padding(.bottom, isLast ? 0 : 20)
}
.contentShape(Rectangle())
}
}