// 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 @Environment(\.modelContext) private var modelContext @StateObject private var vm = StoryDetailViewModel() @Query private var cachedArticles: [CachedArticle] @Query private var readStories: [ReadStory] @Query private var savedStories: [SavedStory] @State private var showShare = false // 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(); 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 isRead: Bool { readStories.contains { $0.id == story.id } } private var isSaved: Bool { savedStories.contains { $0.id == story.id } } private var cachedIds: Set { 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 } ToolbarItemGroup(placement: .topBarTrailing) { Button { showShare = true } label: { Image(systemName: "square.and.arrow.up") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(Color(hex: "888888")) } Button { toggleSave() } label: { Image(systemName: isSaved ? "bookmark.fill" : "bookmark") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(isSaved ? Palette.orange : Color(hex: "888888")) } Button { toggleRead() } label: { Image(systemName: isRead ? "checkmark.circle.fill" : "checkmark.circle") .font(.system(size: 17, weight: .semibold)) .foregroundStyle(isRead ? Palette.orange : Color(hex: "888888")) } } } .toolbarBackground(Color.black, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) .sheet(isPresented: $showShare) { ShareSheet(items: shareContent) } .task { markRead() await vm.load(id: story.id) } } // MARK: - Read / Save / Share private func markRead() { let id = story.id guard (try? modelContext.fetch( FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first == nil else { return } modelContext.insert(ReadStory(id: id)) try? modelContext.save() } private func toggleRead() { let id = story.id if let row = (try? modelContext.fetch( FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first { modelContext.delete(row) } else { modelContext.insert(ReadStory(id: id)) } try? modelContext.save() } private func toggleSave() { let id = story.id if let row = (try? modelContext.fetch( FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first { modelContext.delete(row) } else { // Ensure a CachedStory exists so SavedView can display it. let alreadyCached = (try? modelContext.fetch( FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first != nil if !alreadyCached { modelContext.insert(CachedStory(from: story)) } modelContext.insert(SavedStory(id: id)) } try? modelContext.save() } private var shareContent: [Any] { var items: [Any] = ["\(headline)\n\n\(summary)\n\nvia Jarvis"] if let first = story.sources.first, let url = URL(string: first.url) { items.append(url) } return items } // 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()) } }