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>
This commit is contained in:
245
Jarvis/Views/Home/SignalFeedView.swift
Normal file
245
Jarvis/Views/Home/SignalFeedView.swift
Normal file
@@ -0,0 +1,245 @@
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
89
Jarvis/Views/Home/StoryRowView.swift
Normal file
89
Jarvis/Views/Home/StoryRowView.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
// StoryRowView.swift
|
||||
// Jarvis — one row in the signal feed. Everything fades with the signal score.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct StoryRowView: View {
|
||||
let story: StorySummary
|
||||
/// True when the story's articles are cached in SwiftData (offline-ready).
|
||||
var isCached: Bool = false
|
||||
|
||||
private var sourceNames: [String] { story.sources.map(\.name) }
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
SignalStripe(score: story.signalScore)
|
||||
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
// Title (+ cached dot)
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text(story.headline)
|
||||
.font(.system(size: 17, weight: .heavy))
|
||||
.foregroundStyle(Signal.titleColor(story.signalScore))
|
||||
.lineLimit(3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if isCached {
|
||||
CachedDot()
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
|
||||
// Source chips
|
||||
if !sourceNames.isEmpty {
|
||||
SourceChips(names: sourceNames, total: story.sourceCount)
|
||||
}
|
||||
|
||||
// Metadata
|
||||
Text(metaLine)
|
||||
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(Color(hex: "666666"))
|
||||
}
|
||||
.padding(.leading, 14)
|
||||
.padding(.vertical, 16)
|
||||
|
||||
Spacer(minLength: 12)
|
||||
|
||||
// Signal score column
|
||||
Text("\(story.signalScore)")
|
||||
.font(.system(size: 22, weight: .heavy, design: .monospaced))
|
||||
.foregroundStyle(Signal.scoreColor(story.signalScore))
|
||||
.padding(.trailing, 16)
|
||||
.padding(.top, 16)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.black)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle().fill(Palette.hairline).frame(height: 0.5)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var metaLine: String {
|
||||
let sources = "\(story.sourceCount) source\(story.sourceCount == 1 ? "" : "s")"
|
||||
return "\(sources) · \(Topic.label(story.topic)) · \(story.updatedAt.timeAgoShort()) ago"
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let sources = [
|
||||
StorySource(id: "1", name: "Daily Monitor", url: "", publishedAt: Date(), isBreaking: true),
|
||||
StorySource(id: "2", name: "NilePost", url: "", publishedAt: Date(), isBreaking: false),
|
||||
StorySource(id: "3", name: "The EastAfrican", url: "", publishedAt: Date(), isBreaking: false),
|
||||
]
|
||||
let breakdown = ScoreBreakdown(sourceAuthority: 28, freshness: 22, localRelevance: 15, crossSourceConfirmation: 16, topicImportance: 10)
|
||||
return VStack(spacing: 0) {
|
||||
ForEach([97, 64, 33, 14], id: \.self) { score in
|
||||
StoryRowView(
|
||||
story: StorySummary(
|
||||
id: "\(score)", headline: "Bank of Uganda cuts interest rates by 50 basis points",
|
||||
summary: "", topic: "finance", signalScore: score, scoreBreakdown: breakdown,
|
||||
sourceCount: 7, sources: sources, consensus: nil, conflict: nil,
|
||||
updatedAt: Date().addingTimeInterval(-3600), createdAt: Date()),
|
||||
isCached: score == 64
|
||||
)
|
||||
}
|
||||
}
|
||||
.background(Color.black)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
Reference in New Issue
Block a user