// SignalFeedView.swift // Jarvis — home screen. Stories ranked by signal score, live connection state, // topic filters, pull-to-refresh, infinite scroll. import SwiftUI import SwiftData // Reusable wordmark: J in KisaniOrange, the rest white, weight 800, kerning -2. struct JarvisWordmark: View { var size: CGFloat = 30 var body: some View { (Text("J").foregroundColor(Palette.orange) + Text("arvis").foregroundColor(.white)) .font(.system(size: size, weight: .heavy)) .kerning(-2) } } struct SignalFeedView: View { @EnvironmentObject var store: StoryStore @EnvironmentObject var ws: WebSocketManager @EnvironmentObject var settings: ServerSettings @Environment(\.modelContext) private var modelContext @Query private var cachedStories: [CachedStory] @Query private var cachedArticles: [CachedArticle] @Query private var readStories: [ReadStory] @State private var showFeeds = false @State private var showSettings = false /// Stories that have full article content cached → eligible for the green dot. private var cachedStoryIds: Set { Set(cachedArticles.map(\.storyId)) } private var readStoryIds: Set { Set(readStories.map(\.id)) } /// Live stories when online; cached fallback when the feed is empty & offline. private var displayStories: [StorySummary] { if !store.stories.isEmpty { return store.stories.sorted(by: StorySummary.feedOrder) } if !ws.connectionState.isLive { return cachedStories .map(StorySummary.init(cached:)) .sorted(by: StorySummary.feedOrder) } return [] } var body: some View { NavigationStack { ZStack { Color.black.ignoresSafeArea() VStack(spacing: 0) { header syncBar topicPills columnHeaders Divider().overlay(Palette.hairline) feedList } } .navigationBarHidden(true) .navigationDestination(for: StorySummary.self) { story in StoryDetailView(story: story) } .navigationDestination(for: ArticleRoute.self) { route in ArticleReaderView(route: route) } .sheet(isPresented: $showFeeds) { FeedManagerView() } .sheet(isPresented: $showSettings) { SettingsView() } } .preferredColorScheme(.dark) .onChange(of: store.stories) { _, newValue in cacheStories(newValue) } } // MARK: - Header private var header: some View { HStack(alignment: .center) { JarvisWordmark(size: 30) Spacer() ConnectionBanner(state: ws.connectionState) Button { showFeeds = true } label: { Image(systemName: "dot.radiowaves.up.forward") .font(.system(size: 17, weight: .semibold)) .foregroundStyle(Color(hex: "888888")) .frame(width: 44, height: 44) } Button { showSettings = true } label: { Image(systemName: "gearshape") .font(.system(size: 17, weight: .semibold)) .foregroundStyle(Color(hex: "888888")) .frame(width: 44, height: 44) } } .padding(.horizontal, 16) .padding(.top, 8) } // MARK: - Sync bar private var syncBar: some View { Text(syncText) .font(.system(size: 11, weight: .regular, design: .monospaced)) .foregroundStyle(Color(hex: "5A5A5A")) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) .padding(.bottom, 12) } private var syncText: String { let ago = syncedMinutesAgo(store.lastSyncedAt) let phrase = (ago == "just now" || ago == "never") ? ago : "\(ago) ago" if ws.connectionState.isLive { return "Synced \(phrase) · \(cachedStoryIds.count) cached" } else { return "Last synced \(phrase)" } } // MARK: - Topic pills private var topicPills: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(Topic.filters, id: \.label) { filter in let selected = store.selectedTopic == filter.slug Button { Task { await store.setTopic(filter.slug) } } label: { Text(filter.label) .font(.system(size: 13, weight: .bold)) .foregroundStyle(selected ? .black : Color(hex: "AAAAAA")) .padding(.horizontal, 14) .frame(height: 32) .background(selected ? Palette.orange : Palette.surface) .clipShape(Capsule()) } } } .padding(.horizontal, 16) } .padding(.bottom, 14) } // MARK: - Column headers private var columnHeaders: some View { HStack { Text("STORY") .kerning(0.8) Spacer() Text("SIGNAL ↓") .kerning(0.8) } .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundStyle(Color(hex: "555555")) .padding(.horizontal, 16) .padding(.bottom, 8) } // MARK: - Feed list private var feedList: some View { ScrollView { LazyVStack(spacing: 0) { if displayStories.isEmpty { emptyState } else { ForEach(displayStories) { story in NavigationLink(value: story) { StoryRowView(story: story, isCached: cachedStoryIds.contains(story.id), isRead: readStoryIds.contains(story.id)) } .buttonStyle(.plain) .onAppear { if story.id == displayStories.last?.id { Task { await store.loadMore() } } } } if store.isLoadingMore { ProgressView() .tint(Palette.orange) .padding(.vertical, 20) } } } } .refreshable { await store.loadStories(refresh: true) } } private var emptyState: some View { VStack(spacing: 12) { if store.isLoading { // Actively polling the server. ProgressView().tint(Palette.orange) Text("Polling signals…") .font(.system(size: 14, weight: .heavy)) .foregroundStyle(Color(hex: "AAAAAA")) hostLine } else if let error = store.error { // Reached the empty state because the fetch failed. emptyIcon("exclamationmark.triangle", color: Color(hex: "C25555")) Text("Couldn't reach the server") .font(.system(size: 15, weight: .heavy)).foregroundStyle(.white) Text(error.errorDescription ?? "Unknown error") .font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) .multilineTextAlignment(.center) hostLine refreshButton("Retry") } else if !ws.connectionState.isLive { emptyIcon("wifi.slash") Text("Offline").font(.system(size: 15, weight: .heavy)).foregroundStyle(.white) Text("No cached stories to show") .font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) refreshButton("Retry") } else { // Connected, loaded, genuinely nothing yet. emptyIcon("antenna.radiowaves.left.and.right") Text("No signals yet").font(.system(size: 15, weight: .heavy)).foregroundStyle(.white) Text("Pull to refresh") .font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) refreshButton("Refresh") } } .frame(maxWidth: .infinity) .padding(.horizontal, 32) .padding(.top, 80) } private func emptyIcon(_ name: String, color: Color = Color(hex: "333333")) -> some View { Image(systemName: name).font(.system(size: 30)).foregroundStyle(color) } @ViewBuilder private var hostLine: some View { if let host = settings.host { Text(host) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(Color(hex: "555555")) } } private func refreshButton(_ title: String) -> some View { Button { Task { await store.loadStories(refresh: true) } } label: { Text(title) .font(.system(size: 14, weight: .bold)).foregroundStyle(.black) .padding(.horizontal, 22).frame(height: 44) .background(Palette.orange).clipShape(Capsule()) } .padding(.top, 6) } // MARK: - Caching private func cacheStories(_ stories: [StorySummary]) { for summary in stories { let id = summary.id let existing = try? modelContext.fetch( FetchDescriptor(predicate: #Predicate { $0.id == id }) ) if let found = existing?.first { found.signalScore = summary.signalScore found.sourceCount = summary.sourceCount found.updatedAt = summary.updatedAt } else { modelContext.insert(CachedStory(from: summary)) } } try? modelContext.save() } } // Map a cached story back into a StorySummary for offline rendering. extension StorySummary { init(cached: CachedStory) { self.init( id: cached.id, headline: cached.headline, summary: cached.summary, topic: cached.topic, signalScore: cached.signalScore, scoreBreakdown: ScoreBreakdown(sourceAuthority: 0, freshness: 0, localRelevance: 0, crossSourceConfirmation: 0, topicImportance: 0), sourceCount: cached.sourceCount, sources: [], consensus: cached.consensus, conflict: cached.conflict, updatedAt: cached.updatedAt, createdAt: cached.updatedAt ) } }