Files
jarvis/Jarvis/Views/Home/SignalFeedView.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

246 lines
8.2 KiB
Swift

// 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
@Environment(\.modelContext) private var modelContext
@Query private var cachedStories: [CachedStory]
@Query private var cachedArticles: [CachedArticle]
@State private var showFeeds = false
/// Stories that have full article content cached eligible for the green dot.
private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) }
/// Live stories when online; cached fallback when the feed is empty & offline.
private var displayStories: [StorySummary] {
if !store.stories.isEmpty {
return store.stories.sorted { $0.signalScore > $1.signalScore }
}
if !ws.connectionState.isLive {
return cachedStories
.map(StorySummary.init(cached:))
.sorted { $0.signalScore > $1.signalScore }
}
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()
}
}
.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)
}
}
.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))
}
.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 {
ProgressView().tint(Palette.orange)
} else {
Image(systemName: ws.connectionState.isLive ? "antenna.radiowaves.left.and.right" : "wifi.slash")
.font(.system(size: 30))
.foregroundStyle(Color(hex: "333333"))
Text(ws.connectionState.isLive ? "No signals yet" : "Offline — no cached stories")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(Color(hex: "555555"))
}
}
.frame(maxWidth: .infinity)
.padding(.top, 80)
}
// MARK: - Caching
private func cacheStories(_ stories: [StorySummary]) {
for summary in stories {
let id = summary.id
let existing = try? modelContext.fetch(
FetchDescriptor<CachedStory>(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
)
}
}