Files
jarvis/Jarvis/Views/Home/SignalFeedView.swift
Robin Kutesa 7085a4dd45
Some checks failed
CI / Build · macos-14 · Debug (push) Has been cancelled
CI / Build · macos-15 · Debug (push) Has been cancelled
CI / Build · macos-14 · Release (push) Has been cancelled
CI / Build · macos-15 · Release (push) Has been cancelled
feat(reader): redesign ArticleReaderView + global appearance toggle
Reading experience:
- Body text #CECECE (dark) / #1A1916 (light) — was near-invisible #4A4A4A
- 30pt heavy headline, 17pt body at 8.5pt line-spacing
- Skeleton pulse loading state replaces dark spinner box
- Hero image fades in on load (0.3s ease-in)
- Orange reading progress bar (2pt) below nav bar
- Max reading width 680pt for iPad legibility

Appearance:
- AppearanceMode enum (system/light/dark) in Theme.swift
- @AppStorage("appearanceMode") applied once in JarvisApp root
- Appearance picker (Picker in Menu) in reader toolbar
- Removed .preferredColorScheme(.dark) from all 12 child views

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 01:30:36 +03:00

639 lines
25 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: typewriter font, lowercase j in orange, orange dot on i.
struct JarvisWordmark: View {
var size: CGFloat = 30
private var font: Font { .custom("Courier New", size: size).bold() }
var body: some View {
HStack(spacing: 0) {
Text("j").font(font).foregroundStyle(Palette.orange)
Text("arv").font(font).foregroundStyle(.white)
Text("is").font(font).foregroundStyle(.white)
}
}
}
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]
@Query private var savedStories: [SavedStory]
@State private var showFeeds = false
@State private var showSettings = false
@State private var selectedPill: StoryPill = .all
@State private var shareStory: StorySummary?
/// Snapshot of already-seen story ids, captured per load so the order stays
/// stable while scrolling (and refreshes to sink newly-seen ones).
@State private var seenSnapshot: Set<String> = []
/// Reference-type set so marking a story seen on scroll doesn't re-render.
@State private var seenTracker = SeenTracker()
@State private var showRead = false
/// Stories that have full article content cached eligible for the green dot.
private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) }
private var readStoryIds: Set<String> { Set(readStories.map(\.id)) }
private var savedStoryIds: Set<String> { Set(savedStories.map(\.id)) }
/// Live stories when online; cached fallback when offline. Filtered by the
/// selected pill via backend tags no headline keyword matching.
private var filteredStories: [StorySummary] {
let base: [StorySummary]
if !store.stories.isEmpty {
base = store.stories.sorted(by: StorySummary.feedOrder)
} else if !ws.connectionState.isLive {
base = cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
} else {
base = []
}
return base.filter { $0.matches(selectedPill) }
}
/// The main feed: unread stories, with already-seen ones sunk below fresh ones.
private var mainStories: [StorySummary] {
let notRead = filteredStories.filter { !readStoryIds.contains($0.id) }
let fresh = notRead.filter { !seenSnapshot.contains($0.id) }
let seen = notRead.filter { seenSnapshot.contains($0.id) }
return fresh + seen
}
/// Read stories, tucked into the collapsible "Read" shelf.
private var readItems: [StorySummary] {
filteredStories.filter { readStoryIds.contains($0.id) }
}
/// "All" shows a sectioned front page; specific pills show a flat list.
private var isDigest: Bool { selectedPill == .all }
/// Front-page digest: Top Stories + up to 3 per category section, deduped.
private var digest: (top: [StorySummary], sections: [(NewsSection, [StorySummary])]) {
let unread = filteredStories.filter { !readStoryIds.contains($0.id) }
let top = Array(unread.prefix(5))
var pool = Array(unread.dropFirst(5))
var out: [(NewsSection, [StorySummary])] = []
for section in NewsSection.sections {
var picked: [StorySummary] = [], rest: [StorySummary] = []
for s in pool {
if picked.count < 3 && s.inSection(section) { picked.append(s) }
else { rest.append(s) }
}
pool = rest
if !picked.isEmpty { out.append((section, picked)) }
}
return (top, out)
}
private func captureSeenSnapshot() {
let ids = (try? modelContext.fetch(FetchDescriptor<SeenStory>()))?.map(\.id) ?? []
seenSnapshot = Set(ids)
seenTracker.ids = seenSnapshot
}
/// Hot path runs as each row scrolls in. O(1), no disk write, no re-render:
/// the container autosaves, and SeenStory isn't @Query'd by this view.
private func markSeen(_ s: StorySummary) {
guard seenTracker.ids.insert(s.id).inserted else { return }
modelContext.insert(SeenStory(id: s.id))
}
private func markUnread(_ s: StorySummary) {
let id = s.id
if let row = (try? modelContext.fetch(
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
try? modelContext.save()
}
}
var body: some View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 0) {
header
syncBar
topicPills
if !isDigest { columnHeaders } // sections carry their own headers
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()
}
.sheet(item: $shareStory) { story in
ShareSheet(items: shareItems(story))
}
}
.onChange(of: store.stories) { _, newValue in
cacheStories(newValue)
}
.task { CacheMaintenance.prune(modelContext) } // bound the caches on launch
.task(id: selectedPill) {
await store.setPill(selectedPill)
var pages = 0
if selectedPill == .all {
// Front page: pull enough stories to fill the sections.
while store.stories.count < 100 && store.hasMore && pages < 5 {
await store.loadMore(); pages += 1
}
}
}
}
// 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: - Pills
private var topicPills: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(StoryPill.allCases) { p in
pill(p.label, selected: selectedPill == p) {
selectedPill = p
}
}
}
.padding(.horizontal, 16)
}
.padding(.bottom, 14)
}
private func pill(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(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())
}
}
// 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 {
List {
if mainStories.isEmpty && readItems.isEmpty {
emptyState.plainBlackRow()
} else if isDigest {
digestContent
} else {
flatContent
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.black)
.animation(.snappy, value: showRead)
.onAppear { captureSeenSnapshot() }
.refreshable {
captureSeenSnapshot() // sink what you've already seen
await store.loadStories(refresh: true)
}
}
/// Flat list for a specific pill.
@ViewBuilder private var flatContent: some View {
ForEach(mainStories) { mainRow($0) }
if mainStories.isEmpty { caughtUpRow.plainBlackRow() }
if store.isLoadingMore {
ProgressView().tint(Palette.orange)
.frame(maxWidth: .infinity).padding(.vertical, 20).plainBlackRow()
}
readShelf
}
/// Sectioned front page for "All".
@ViewBuilder private var digestContent: some View {
let d = digest
sectionHeader("TOP STORIES", pill: nil)
if let lead = d.top.first {
mainRow(lead, hero: true)
ForEach(d.top.dropFirst()) { mainRow($0) }
}
ForEach(d.sections, id: \.0.id) { section, stories in
sectionHeader(section.label.uppercased(), pill: section.pill)
ForEach(stories) { mainRow($0) }
}
readShelf
}
private func sectionHeader(_ label: String, pill: StoryPill?) -> some View {
HStack(alignment: .firstTextBaseline) {
Text(label)
.font(.system(size: 13, weight: .heavy, design: .monospaced))
.kerning(0.6)
.foregroundStyle(.white)
Rectangle().fill(Palette.orange).frame(width: 18, height: 2)
Spacer()
if let pill {
Button {
selectedPill = pill
} label: {
HStack(spacing: 2) {
Text("See all"); Image(systemName: "chevron.right").font(.system(size: 10, weight: .bold))
}
.font(.system(size: 12, weight: .bold))
.foregroundStyle(Palette.orange)
}
}
}
.padding(.horizontal, 16).padding(.top, 22).padding(.bottom, 10)
.plainBlackRow()
}
@ViewBuilder
private func mainRow(_ story: StorySummary, hero: Bool = false) -> some View {
NavigationLink(value: story) {
if hero {
heroCard(story)
} else {
StoryRowView(story: story, isCached: cachedStoryIds.contains(story.id))
}
}
.listRowInsets(hero ? EdgeInsets(top: 2, leading: 16, bottom: 12, trailing: 16) : EdgeInsets())
.listRowBackground(Color.black)
.listRowSeparator(.hidden)
.onAppear {
markSeen(story)
if story.id == mainStories.last?.id { Task { await store.loadMore() } }
}
// Swipe left: short swipe reveals Save (tap); a full/long swipe marks read.
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { markRead(story) } label: {
Label("Mark read", systemImage: "checkmark.circle")
}
.tint(Color(hex: "3A5A7A"))
Button { toggleSave(story) } label: {
Label(isSaved(story) ? "Saved" : "Save",
systemImage: isSaved(story) ? "bookmark.fill" : "bookmark")
}
.tint(Palette.healthActive)
}
// Swipe right Share.
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button { markRead(story); shareStory = story } label: {
Label("Share", systemImage: "square.and.arrow.up")
}
.tint(Palette.orange)
}
}
/// Apple-News-style lead card for the #1 Top Story flat surface, big type.
private func heroCard(_ story: StorySummary) -> some View {
HStack(alignment: .top, spacing: 0) {
SignalStripe(score: story.signalScore, width: 4)
VStack(alignment: .leading, spacing: 11) {
HStack(alignment: .firstTextBaseline) {
Text(Topic.label(story.topic).uppercased())
.font(.system(size: 10, weight: .bold, design: .monospaced)).kerning(0.8)
.foregroundStyle(Color(hex: "8A8A8A"))
Spacer()
Text("\(story.signalScore)")
.font(.system(size: 21, weight: .heavy, design: .monospaced))
.foregroundStyle(Signal.scoreColor(story.signalScore))
}
Text(story.headline)
.font(.system(size: 24, weight: .heavy))
.foregroundStyle(.white)
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
if !story.summary.isEmpty {
Text(story.summary)
.font(.system(size: 14))
.foregroundStyle(Color(hex: "9A9A9A"))
.lineLimit(3).lineSpacing(2)
.fixedSize(horizontal: false, vertical: true)
}
if !story.sources.isEmpty {
SourceChips(names: story.sources.map(\.name), total: story.sourceCount)
}
Text("\(story.sourceCount) sources · \(story.updatedAt.timeAgoShort()) ago")
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(Color(hex: "666666"))
}
.padding(16)
Spacer(minLength: 0)
}
.background(Palette.surface)
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Palette.surface2, lineWidth: 0.5))
}
private var caughtUpRow: some View {
VStack(spacing: 10) {
Image(systemName: "checkmark.circle")
.font(.system(size: 32)).foregroundStyle(Color(hex: "3E7E3E"))
Text("You're all caught up").font(.system(size: 16, weight: .heavy)).foregroundStyle(.white)
Text("Pull to refresh for more")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
}
.frame(maxWidth: .infinity).padding(.vertical, 70)
}
// Collapsible "Read" shelf at the bottom read stories as slim rows.
@ViewBuilder
private var readShelf: some View {
if !readItems.isEmpty {
Button {
withAnimation(.snappy) { showRead.toggle() }
} label: {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 13)).foregroundStyle(Color(hex: "3E7E3E"))
Text("READ").font(.system(size: 11, weight: .bold, design: .monospaced)).kerning(0.8)
Text("\(readItems.count)")
.font(.system(size: 11, weight: .bold, design: .monospaced))
.foregroundStyle(Color(hex: "888888"))
Spacer()
Image(systemName: showRead ? "chevron.up" : "chevron.down")
.font(.system(size: 11, weight: .bold))
}
.foregroundStyle(Color(hex: "666666"))
.padding(.horizontal, 16).padding(.vertical, 16)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.plainBlackRow()
if showRead {
ForEach(readItems) { story in
NavigationLink(value: story) {
StoryRowView(story: story, isCached: cachedStoryIds.contains(story.id),
isRead: true, compact: true)
}
.plainBlackRow()
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { markUnread(story) } label: {
Label("Unread", systemImage: "circle")
}
.tint(Color(hex: "3A5A7A"))
Button { toggleSave(story) } label: {
Label(isSaved(story) ? "Saved" : "Save",
systemImage: isSaved(story) ? "bookmark.fill" : "bookmark")
}
.tint(Palette.healthActive)
}
}
}
}
}
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 if selectedPill != .all {
emptyIcon("line.3.horizontal.decrease.circle")
Text("No \(selectedPill.label) stories yet")
.font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
if store.isLoadingMore || store.hasMore {
Text("Pulling more to find them…")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
ProgressView().tint(Palette.orange)
} else {
Text("Nothing in the feed matched")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
refreshButton("Refresh")
}
} 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: - Swipe actions
private func isSaved(_ s: StorySummary) -> Bool { savedStoryIds.contains(s.id) }
/// Swipe left toggle Save (bookmarks the story to the Saved tab).
private func toggleSave(_ s: StorySummary) {
let id = s.id
if let row = (try? modelContext.fetch(
FetchDescriptor<SavedStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
} else {
cacheStories([s]) // keep metadata so it renders in Saved
modelContext.insert(SavedStory(id: id))
}
try? modelContext.save()
}
private func markRead(_ s: StorySummary) {
let id = s.id
let existing = try? modelContext.fetch(
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id }))
if existing?.first == nil {
modelContext.insert(ReadStory(id: id))
try? modelContext.save()
}
}
private func shareItems(_ s: StorySummary) -> [Any] {
var items: [Any] = ["\(s.headline)\n\n\(s.summary)\n\nvia Jarvis"]
if let first = s.sources.first, let url = URL(string: first.url) {
items.append(url)
}
return items
}
// MARK: - Caching
private func cacheStories(_ stories: [StorySummary]) {
guard !stories.isEmpty else { return }
// One fetch + an in-memory index, instead of a query per story.
let existing = (try? modelContext.fetch(FetchDescriptor<CachedStory>())) ?? []
var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
for summary in stories {
if let found = byId[summary.id] {
found.signalScore = summary.signalScore
found.sourceCount = summary.sourceCount
found.tags = summary.tags ?? []
found.updatedAt = summary.updatedAt
} else {
let c = CachedStory(from: summary)
modelContext.insert(c)
byId[summary.id] = c
}
}
try? modelContext.save()
}
}
/// Session-seen ids in a reference type mutating it doesn't trigger SwiftUI
/// updates, so marking rows seen on scroll stays cheap.
private final class SeenTracker {
var ids = Set<String>()
}
// 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,
tags: cached.tags,
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
)
}
}
private extension View {
/// Full-bleed black List row with no separator keeps the flat feed look.
func plainBlackRow() -> some View {
self.listRowInsets(EdgeInsets())
.listRowBackground(Color.black)
.listRowSeparator(.hidden)
}
}