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:
Robin Kutesa
2026-06-18 18:04:59 +03:00
commit 27d0e22767
45 changed files with 4913 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x00",
"green" : "0x5C",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x00",
"green" : "0x5C",
"red" : "0xFF"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x00",
"green" : "0x5C",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,111 @@
// ConnectivityManager.swift
// Jarvis resolves which endpoint to use by probing /health, activates it on the
// APIClient + WebSocket, and re-resolves when the network path changes.
import Foundation
import Network
enum Reachability: Equatable {
case unknown, checking, reachable, unreachable
}
@MainActor
final class ConnectivityManager: ObservableObject {
static let shared = ConnectivityManager()
/// The endpoint currently in use (nil = nothing reachable).
@Published var activeHost: String?
/// Per-host probe results, for the settings UI badges.
@Published var status: [String: Reachability] = [:]
@Published var isResolving = false
private let settings = ConnectivitySettings.shared
private let probeSession: URLSession
private var monitor: NWPathMonitor?
private var lastActivated: String?
private var resolveTask: Task<Void, Never>?
private init() {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 2.5
cfg.waitsForConnectivity = false
probeSession = URLSession(configuration: cfg)
}
/// Begin watching the network path; re-resolve (debounced) on changes.
func start() {
guard monitor == nil else { return }
let m = NWPathMonitor()
m.pathUpdateHandler = { [weak self] _ in
Task { @MainActor in self?.scheduleResolve() }
}
m.start(queue: DispatchQueue(label: "jarvis.connectivity"))
monitor = m
}
private func scheduleResolve() {
guard settings.autoConnect else { return } // only auto-switch when enabled
resolveTask?.cancel()
let delay = UInt64(max(1, settings.switchDelaySeconds)) * 1_000_000_000
resolveTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: delay)
guard !Task.isCancelled else { return }
await self?.resolveAndActivate()
}
}
/// Probe candidates in priority order; activate the first that answers.
func resolveAndActivate() async {
guard !isResolving else { return }
let candidates = settings.candidates
guard !candidates.isEmpty else { return }
isResolving = true
defer { isResolving = false }
for host in candidates {
status[host] = .checking
if await probe(host) {
status[host] = .reachable
await activate(host)
return
} else {
status[host] = .unreachable
}
}
// Nothing reachable.
activeHost = nil
}
/// Probe a single host and record its status (used by the settings screen).
@discardableResult
func check(_ host: String) async -> Bool {
let h = host.trimmingCharacters(in: .whitespaces)
guard !h.isEmpty else { return false }
status[h] = .checking
let ok = await probe(h)
status[h] = ok ? .reachable : .unreachable
return ok
}
// MARK: - Private
private func probe(_ host: String) async -> Bool {
guard let url = URL(string: "http://\(host)/api/v1/health") else { return false }
var req = URLRequest(url: url)
req.httpMethod = "GET"
do {
let (_, response) = try await probeSession.data(for: req)
return (response as? HTTPURLResponse).map { (200...299).contains($0.statusCode) } ?? false
} catch {
return false
}
}
private func activate(_ host: String) async {
activeHost = host
guard host != lastActivated else { return } // avoid thrashing the WS
lastActivated = host
await ServerSettings.shared.activate(host: host)
await StoryStore.shared.loadStories(refresh: true)
}
}

View File

@@ -0,0 +1,73 @@
// ConnectivitySettings.swift
// Jarvis persisted connection profiles: a Direct/LAN address and a Remote/VPN
// address for the same server, the chosen provider, and the auto-switch policy.
import Foundation
@MainActor
final class ConnectivitySettings: ObservableObject {
static let shared = ConnectivitySettings()
private let key = "jarvis_connectivity_v2"
@Published var provider: VPNProvider { didSet { persist() } }
@Published var directHost: String { didSet { persist() } } // LAN, e.g. 192.168.30.50:8080
@Published var remoteHost: String { didSet { persist() } } // Tailscale/VPN address
/// "Use <provider> when remote" when off, only the LAN address is ever used.
@Published var remoteEnabled: Bool { didSet { persist() } }
/// "Auto-connect" re-resolve automatically when the network path changes.
@Published var autoConnect: Bool { didSet { persist() } }
/// Debounce before switching endpoints after a network change (seconds).
@Published var switchDelaySeconds: Int { didSet { persist() } }
private struct Stored: Codable {
var provider: VPNProvider
var directHost: String
var remoteHost: String
var remoteEnabled: Bool?
var autoConnect: Bool?
var switchDelaySeconds: Int?
}
private init() {
if let data = UserDefaults.standard.data(forKey: key),
let s = try? JSONDecoder().decode(Stored.self, from: data) {
provider = s.provider
directHost = s.directHost
remoteHost = s.remoteHost
remoteEnabled = s.remoteEnabled ?? true
autoConnect = s.autoConnect ?? true
switchDelaySeconds = s.switchDelaySeconds ?? 5
} else {
provider = .tailscale
directHost = UserDefaults.standard.string(forKey: "jarvis_server_host") ?? ""
remoteHost = ""
remoteEnabled = true
autoConnect = true
switchDelaySeconds = 5
}
}
private func persist() {
let s = Stored(provider: provider, directHost: directHost, remoteHost: remoteHost,
remoteEnabled: remoteEnabled, autoConnect: autoConnect,
switchDelaySeconds: switchDelaySeconds)
if let data = try? JSONEncoder().encode(s) {
UserDefaults.standard.set(data, forKey: key)
}
}
/// Whether the remote endpoint participates in resolution right now.
var remoteActiveInPolicy: Bool { provider.needsRemote && remoteEnabled }
/// Hosts to probe, in priority order (LAN first), de-duplicated and trimmed.
var candidates: [String] {
let d = directHost.trimmingCharacters(in: .whitespaces)
let r = remoteActiveInPolicy ? remoteHost.trimmingCharacters(in: .whitespaces) : ""
var seen = Set<String>(); var out: [String] = []
for h in [d, r] where !h.isEmpty && !seen.contains(h) { seen.insert(h); out.append(h) }
return out
}
var isConfigured: Bool { !candidates.isEmpty }
}

View File

@@ -0,0 +1,101 @@
// VPNProvider.swift
// Jarvis remote-access providers. The app can't start another app's VPN tunnel
// (iOS sandboxing), so the provider only tailors hints, help text, and an
// "open the app" jump. Reachability + endpoint selection is provider-agnostic.
import UIKit
enum VPNProvider: String, CaseIterable, Codable, Identifiable {
case direct // LAN only, no VPN
case tailscale
case headscale // self-hosted control plane, Tailscale clients
case netbird
case zerotier
case netmaker
case wireguard
var id: String { rawValue }
var displayName: String {
switch self {
case .direct: return "Direct (LAN)"
case .tailscale: return "Tailscale"
case .headscale: return "Headscale"
case .netbird: return "NetBird"
case .zerotier: return "ZeroTier"
case .netmaker: return "Netmaker"
case .wireguard: return "WireGuard"
}
}
/// Whether a separate remote/VPN address is meaningful for this provider.
var needsRemote: Bool { self != .direct }
/// Placeholder shown in the remote-address field.
var remoteHint: String {
switch self {
case .direct: return ""
case .tailscale: return "jarvis.tailnet.ts.net:8080 or 100.x.y.z:8080"
case .headscale: return "100.x.y.z:8080 (MagicDNS or tailnet IP)"
case .netbird: return "100.x.y.z:8080"
case .zerotier: return "10.x.x.x:8080 (managed IP)"
case .netmaker: return "10.x.x.x:8080"
case .wireguard: return "10.0.0.x:8080 (peer IP)"
}
}
var blurb: String {
switch self {
case .direct:
return "Use the LAN address only. Best when the phone is always on the home network."
case .tailscale:
return "On your home wifi Jarvis uses the LAN address directly; away, it uses your Tailscale address. Make sure Tailscale is connected when remote."
case .headscale:
return "Self-hosted Tailscale control plane. Same client behaviour as Tailscale — keep the client connected when away."
case .netbird:
return "Open-source WireGuard mesh. Keep the NetBird app connected when off the LAN."
case .zerotier:
return "Managed network via ZeroTier. Keep the ZeroTier app joined to the network when remote."
case .netmaker:
return "Self-hosted WireGuard mesh. Keep your Netmaker client up when off the LAN."
case .wireguard:
return "Plain WireGuard tunnel. Enable the tunnel in the WireGuard app when remote."
}
}
/// Best-effort URL scheme + App Store fallback used by "Open <provider>".
/// iOS can't start the tunnel for us this just brings the app forward.
private var urlScheme: URL? {
switch self {
case .tailscale, .headscale: return URL(string: "tailscale://")
case .zerotier: return URL(string: "zerotier://")
case .wireguard: return URL(string: "wireguard://")
default: return nil
}
}
private var appStoreURL: URL? {
switch self {
case .tailscale, .headscale: return URL(string: "https://apps.apple.com/app/id1470499037")
case .zerotier: return URL(string: "https://apps.apple.com/app/id1084101492")
case .wireguard: return URL(string: "https://apps.apple.com/app/id1441195209")
case .netbird: return URL(string: "https://apps.apple.com/app/id6443155627")
default: return nil
}
}
var canOpenApp: Bool { urlScheme != nil || appStoreURL != nil }
/// Try the app's scheme; fall back to its App Store page.
@MainActor
func openApp() {
let candidates = [urlScheme, appStoreURL].compactMap { $0 }
func attempt(_ i: Int) {
guard i < candidates.count else { return }
UIApplication.shared.open(candidates[i]) { ok in
if !ok { attempt(i + 1) }
}
}
attempt(0)
}
}

50
Jarvis/Info.plist Normal file
View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Jarvis</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>tailscale</string>
<string>zerotier</string>
<string>wireguard</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Jarvis connects to your self-hosted news platform on the local network.</string>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string></string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
</dict>
</plist>

35
Jarvis/JarvisApp.swift Normal file
View File

@@ -0,0 +1,35 @@
// JarvisApp.swift
import SwiftUI
import SwiftData
@main
struct JarvisApp: App {
@StateObject private var settings = ServerSettings.shared
@StateObject private var store = StoryStore.shared
@StateObject private var ws = WebSocketManager.shared
@StateObject private var connectivity = ConnectivitySettings.shared
@StateObject private var connManager = ConnectivityManager.shared
var body: some Scene {
WindowGroup {
Group {
if settings.isConfigured {
RootTabView()
} else {
OnboardingView()
}
}
.environmentObject(settings)
.environmentObject(store)
.environmentObject(ws)
.environmentObject(connectivity)
.environmentObject(connManager)
.task {
connManager.start()
await connManager.resolveAndActivate()
}
}
.modelContainer(for: [CachedStory.self, CachedArticle.self])
}
}

214
Jarvis/Models/Models.swift Normal file
View File

@@ -0,0 +1,214 @@
// Models.swift
// Jarvis all Codable structs matching the API contract
import Foundation
import SwiftData
// MARK: - Signal Score
struct SignalScore: Codable, Hashable {
let total: Int
let sourceAuthority: Int
let freshness: Int
let localRelevance: Int
let crossSourceConfirmation: Int
let topicImportance: Int
enum CodingKeys: String, CodingKey {
case total = "signalScore"
case sourceAuthority, freshness, localRelevance
case crossSourceConfirmation, topicImportance
}
}
// MARK: - Source
struct StorySource: Codable, Hashable, Identifiable {
let id: String
let name: String
let url: String
let publishedAt: Date
let isBreaking: Bool
}
// MARK: - Timeline Entry
struct TimelineEntry: Codable, Hashable, Identifiable {
let articleId: String
let source: String
let headline: String
let publishedAt: Date
let isBreaking: Bool
var id: String { articleId }
}
// MARK: - Story Summary (list view)
struct StorySummary: Codable, Identifiable, Hashable {
let id: String
let headline: String
let summary: String
let topic: String
let signalScore: Int
let scoreBreakdown: ScoreBreakdown
let sourceCount: Int
let sources: [StorySource]
let consensus: String?
let conflict: String?
let updatedAt: Date
let createdAt: Date
}
struct ScoreBreakdown: Codable, Hashable {
let sourceAuthority: Int
let freshness: Int
let localRelevance: Int
let crossSourceConfirmation: Int
let topicImportance: Int
}
// MARK: - Story Detail (full, with timeline)
struct StoryDetail: Codable, Identifiable {
let id: String
let headline: String
let summary: String
let topic: String
let signalScore: Int
let scoreBreakdown: ScoreBreakdown
let sourceCount: Int
let consensus: String?
let conflict: String?
let timeline: [TimelineEntry]
let updatedAt: Date
let createdAt: Date
}
// MARK: - Article (full content, for offline cache)
struct Article: Codable, Identifiable {
let id: String
let storyId: String
let source: String
let sourceUrl: String
let headline: String
let body: String
let imageUrl: String?
let author: String?
let publishedAt: Date
}
// MARK: - Feed
struct Feed: Codable, Identifiable {
let id: String
let name: String
let url: String
let health: FeedHealth
let pollIntervalSeconds: Int
let failureCount: Int
let lastFetchedAt: Date?
let articleCountToday: Int
}
enum FeedHealth: String, Codable {
case active, failing, dead
}
// MARK: - Server Health
struct ServerHealth: Codable {
let status: String
let version: String
let storiesCount: Int
let feedsCount: Int
let uptime: Int
}
// MARK: - Paginated Response
struct PaginatedStories: Codable {
let data: [StorySummary]
let nextCursor: String?
let hasMore: Bool
let total: Int
}
// MARK: - WebSocket Events
enum WSEventType: String, Codable {
case storyUpdated = "story.updated"
case storyCreated = "story.created"
case storyStale = "story.stale"
case feedHealth = "feed.health"
case ping, pong
}
struct WSEvent: Codable {
let type: WSEventType
let storyId: String?
let feedId: String?
let signalScore: Int?
let sourceCount: Int?
let headline: String?
let topic: String?
let health: FeedHealth?
let failureCount: Int?
let updatedAt: Date?
let createdAt: Date?
}
// MARK: - SwiftData cached models
@Model
final class CachedStory {
@Attribute(.unique) var id: String
var headline: String
var summary: String
var topic: String
var signalScore: Int
var sourceCount: Int
var consensus: String?
var conflict: String?
var updatedAt: Date
var cachedAt: Date
init(from summary: StorySummary) {
self.id = summary.id
self.headline = summary.headline
self.summary = summary.summary
self.topic = summary.topic
self.signalScore = summary.signalScore
self.sourceCount = summary.sourceCount
self.consensus = summary.consensus
self.conflict = summary.conflict
self.updatedAt = summary.updatedAt
self.cachedAt = Date()
}
}
@Model
final class CachedArticle {
@Attribute(.unique) var id: String
var storyId: String
var source: String
var headline: String
var body: String
var imageUrl: String?
var author: String?
var publishedAt: Date
var cachedAt: Date
init(from article: Article) {
self.id = article.id
self.storyId = article.storyId
self.source = article.source
self.headline = article.headline
self.body = article.body
self.imageUrl = article.imageUrl
self.author = article.author
self.publishedAt = article.publishedAt
self.cachedAt = Date()
}
}

View File

@@ -0,0 +1,159 @@
// APIClient.swift
// Jarvis REST networking layer
import Foundation
enum APIError: Error, LocalizedError {
case invalidURL
case serverError(code: String, message: String, status: Int)
case decodingError(Error)
case networkError(Error)
case notConnected
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid server URL"
case .notConnected: return "Not connected to a Jarvis server"
case .serverError(_, let msg, _): return msg
case .decodingError(let e): return "Decode error: \(e.localizedDescription)"
case .networkError(let e): return e.localizedDescription
}
}
}
private struct APIErrorResponse: Decodable {
struct Inner: Decodable { let code, message: String; let status: Int }
let error: Inner
}
actor APIClient {
static let shared = APIClient()
private var baseURL: URL?
private let session: URLSession
private let decoder: JSONDecoder
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 10
self.session = URLSession(configuration: config)
self.decoder = JSONDecoder()
self.decoder.dateDecodingStrategy = .iso8601
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
}
func configure(host: String) throws {
guard let url = URL(string: "http://\(host)/api/v1") else {
throw APIError.invalidURL
}
self.baseURL = url
}
// MARK: - Health
func checkHealth() async throws -> ServerHealth {
try await get("/health")
}
// MARK: - Stories
func fetchStories(
cursor: String? = nil,
topic: String? = nil,
minSignal: Int? = nil,
limit: Int = 20
) async throws -> PaginatedStories {
var params: [String: String] = ["limit": "\(limit)"]
if let cursor { params["after"] = cursor }
if let topic { params["topic"] = topic }
if let minSignal { params["min_signal"] = "\(minSignal)" }
return try await get("/stories", params: params)
}
func fetchStory(id: String) async throws -> StoryDetail {
try await get("/stories/\(id)")
}
// MARK: - Articles
func fetchArticle(id: String) async throws -> Article {
try await get("/articles/\(id)")
}
// MARK: - Feeds
func fetchFeeds() async throws -> [Feed] {
struct FeedsResponse: Decodable { let data: [Feed] }
let response: FeedsResponse = try await get("/feeds")
return response.data
}
func addFeed(url: String, name: String, pollInterval: Int = 900) async throws -> Feed {
let body = ["url": url, "name": name, "pollIntervalSeconds": pollInterval] as [String: Any]
return try await post("/feeds", body: body)
}
func deleteFeed(id: String) async throws {
try await delete("/feeds/\(id)")
}
// MARK: - Private helpers
private func get<T: Decodable>(_ path: String, params: [String: String] = [:]) async throws -> T {
guard let base = baseURL else { throw APIError.notConnected }
var components = URLComponents(url: base.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
if !params.isEmpty {
components.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) }
}
guard let url = components.url else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = "GET"
return try await execute(request)
}
private func post<T: Decodable>(_ path: String, body: [String: Any]) async throws -> T {
guard let base = baseURL else { throw APIError.notConnected }
let url = base.appendingPathComponent(path)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
return try await execute(request)
}
private func delete(_ path: String) async throws {
guard let base = baseURL else { throw APIError.notConnected }
let url = base.appendingPathComponent(path)
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
let (_, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 204 else {
throw APIError.serverError(code: "delete_failed", message: "Delete failed", status: 500)
}
}
private func execute<T: Decodable>(_ request: URLRequest) async throws -> T {
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw APIError.networkError(URLError(.badServerResponse))
}
if !(200...299).contains(http.statusCode) {
if let err = try? decoder.decode(APIErrorResponse.self, from: data) {
throw APIError.serverError(code: err.error.code, message: err.error.message, status: err.error.status)
}
throw APIError.serverError(code: "unknown", message: "HTTP \(http.statusCode)", status: http.statusCode)
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decodingError(error)
}
} catch let error as APIError {
throw error
} catch {
throw APIError.networkError(error)
}
}
}

View File

@@ -0,0 +1,128 @@
// WebSocketManager.swift
// Jarvis WebSocket connection, event parsing, reconnect with backoff
import Foundation
import Combine
enum ConnectionState {
case disconnected
case connecting
case connected
case reconnecting(attempt: Int)
}
@MainActor
final class WebSocketManager: ObservableObject {
static let shared = WebSocketManager()
@Published var connectionState: ConnectionState = .disconnected
let events = PassthroughSubject<WSEvent, Never>()
private var task: URLSessionWebSocketTask?
private var pingTimer: Timer?
private var reconnectTask: Task<Void, Never>?
private var host: String?
private let maxBackoff: TimeInterval = 60
private var attempt = 0
private init() {}
func connect(host: String) {
self.host = host
self.attempt = 0
openConnection()
}
func disconnect() {
reconnectTask?.cancel()
pingTimer?.invalidate()
task?.cancel(with: .goingAway, reason: nil)
task = nil
connectionState = .disconnected
}
// MARK: - Private
private func openConnection() {
guard let host, let url = URL(string: "ws://\(host)/ws") else { return }
connectionState = attempt == 0 ? .connecting : .reconnecting(attempt: attempt)
let session = URLSession(configuration: .default)
task = session.webSocketTask(with: url)
task?.resume()
connectionState = .connected
attempt = 0
startPing()
listen()
}
private func listen() {
task?.receive { [weak self] result in
guard let self else { return }
switch result {
case .failure:
Task { @MainActor in self.handleDisconnect() }
case .success(let message):
Task { @MainActor in
self.handle(message: message)
self.listen()
}
}
}
}
private func handle(message: URLSessionWebSocketTask.Message) {
guard case .string(let text) = message,
let data = text.data(using: .utf8) else { return }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
guard let event = try? decoder.decode(WSEvent.self, from: data) else { return }
if event.type == .ping {
sendPong()
return
}
events.send(event)
}
private func sendPong() {
task?.send(.string(#"{"type":"pong"}"#)) { _ in }
}
private func startPing() {
pingTimer?.invalidate()
pingTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
self?.task?.sendPing { error in
if error != nil {
Task { @MainActor in self?.handleDisconnect() }
}
}
}
}
private func handleDisconnect() {
pingTimer?.invalidate()
task = nil
connectionState = .disconnected
scheduleReconnect()
}
private func scheduleReconnect() {
attempt += 1
let delay = min(pow(2.0, Double(attempt - 1)), maxBackoff)
connectionState = .reconnecting(attempt: attempt)
reconnectTask = Task {
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard !Task.isCancelled else { return }
await MainActor.run { self.openConnection() }
}
}
}

View File

@@ -0,0 +1,48 @@
// ServerSettings.swift
// Jarvis persists the configured server host
import Foundation
@MainActor
final class ServerSettings: ObservableObject {
static let shared = ServerSettings()
private let key = "jarvis_server_host"
@Published var host: String? {
didSet { UserDefaults.standard.set(host, forKey: key) }
}
var isConfigured: Bool { host != nil }
private init() {
host = UserDefaults.standard.string(forKey: key)
}
func save(host: String) async throws {
try await APIClient.shared.configure(host: host)
_ = try await APIClient.shared.checkHealth()
self.host = host
WebSocketManager.shared.connect(host: host)
}
func reset() {
host = nil
WebSocketManager.shared.disconnect()
}
/// Re-establish REST + WebSocket from a previously saved host on app launch.
func reconnectIfConfigured() async {
guard let host else { return }
try? await APIClient.shared.configure(host: host)
WebSocketManager.shared.connect(host: host)
}
/// Point REST + WebSocket at a host the ConnectivityManager already verified
/// (skips the health check the probe just confirmed it).
func activate(host: String) async {
try? await APIClient.shared.configure(host: host)
self.host = host
WebSocketManager.shared.connect(host: host)
}
}

View File

@@ -0,0 +1,143 @@
// StoryStore.swift
// Jarvis central observable store. Views read from here, never hit the API directly.
import Foundation
import Combine
import SwiftData
@MainActor
final class StoryStore: ObservableObject {
static let shared = StoryStore()
@Published var stories: [StorySummary] = []
@Published var isLoading = false
@Published var isLoadingMore = false
@Published var error: APIError?
@Published var lastSyncedAt: Date?
@Published var hasMore = false
@Published var selectedTopic: String? = nil
private var nextCursor: String?
private var cancellables = Set<AnyCancellable>()
private let ws = WebSocketManager.shared
private let api = APIClient.shared
private init() {
subscribeToWebSocket()
}
// MARK: - Load
func loadStories(refresh: Bool = false) async {
guard !isLoading else { return }
isLoading = true
error = nil
if refresh {
nextCursor = nil
stories = []
}
do {
let result = try await api.fetchStories(
cursor: nextCursor,
topic: selectedTopic
)
stories = refresh ? result.data : stories + result.data
nextCursor = result.nextCursor
hasMore = result.hasMore
lastSyncedAt = Date()
} catch let e as APIError {
error = e
} catch {}
isLoading = false
}
func loadMore() async {
guard hasMore, !isLoadingMore, let cursor = nextCursor else { return }
isLoadingMore = true
do {
let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic)
stories += result.data
nextCursor = result.nextCursor
hasMore = result.hasMore
} catch {}
isLoadingMore = false
}
func setTopic(_ topic: String?) async {
selectedTopic = topic
await loadStories(refresh: true)
}
// MARK: - WebSocket
private func subscribeToWebSocket() {
ws.events
.receive(on: DispatchQueue.main)
.sink { [weak self] event in
self?.handle(event: event)
}
.store(in: &cancellables)
}
private func handle(event: WSEvent) {
switch event.type {
case .storyUpdated:
guard let id = event.storyId,
let score = event.signalScore,
let count = event.sourceCount else { return }
updateStory(id: id, signalScore: score, sourceCount: count)
Task { await refetch(storyId: id) }
case .storyCreated:
Task { await loadStories(refresh: true) }
case .storyStale:
guard let id = event.storyId else { return }
removeStory(id: id)
case .feedHealth:
NotificationCenter.default.post(name: .feedHealthChanged, object: event)
default:
break
}
}
private func updateStory(id: String, signalScore: Int, sourceCount: Int) {
guard let idx = stories.firstIndex(where: { $0.id == id }) else { return }
let old = stories[idx]
let updated = StorySummary(
id: old.id,
headline: old.headline,
summary: old.summary,
topic: old.topic,
signalScore: signalScore,
scoreBreakdown: old.scoreBreakdown,
sourceCount: sourceCount,
sources: old.sources,
consensus: old.consensus,
conflict: old.conflict,
updatedAt: old.updatedAt,
createdAt: old.createdAt
)
stories[idx] = updated
stories.sort { $0.signalScore > $1.signalScore }
}
private func removeStory(id: String) {
stories.removeAll { $0.id == id }
}
private func refetch(storyId: String) async {
// Lightweight: only update what changed via WS patch above.
// Full detail is fetched lazily when user taps into the story.
}
}
extension Notification.Name {
static let feedHealthChanged = Notification.Name("feedHealthChanged")
}

View File

@@ -0,0 +1,322 @@
// ConnectivityView.swift
// Jarvis server address, a Remote Access card (Use <provider> when remote +
// host + live status), and an Auto-connect automation card. The app can't start
// the VPN tunnel (iOS), so when the remote is down we offer a one-tap jump.
import SwiftUI
struct ConnectivityView: View {
@EnvironmentObject var connectivity: ConnectivitySettings
@EnvironmentObject var manager: ConnectivityManager
@Environment(\.dismiss) private var dismiss
private var directReach: Reachability {
manager.status[connectivity.directHost.trimmingCharacters(in: .whitespaces)] ?? .unknown
}
private var remoteReach: Reachability {
manager.status[connectivity.remoteHost.trimmingCharacters(in: .whitespaces)] ?? .unknown
}
private var providerName: String { connectivity.provider.displayName }
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 26) {
header
serverSection
remoteAccessSection
automationSection
providerSection
actions
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
}
.preferredColorScheme(.dark)
.presentationDragIndicator(.visible)
.task { await recheck() }
}
// MARK: - Header
private var header: some View {
HStack {
Text("Connectivity")
.font(.system(size: 22, weight: .heavy))
.foregroundStyle(.white)
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(Color(hex: "888888"))
.frame(width: 44, height: 44)
}
}
.padding(.top, 8)
}
// MARK: - Server (LAN)
private var serverSection: some View {
VStack(alignment: .leading, spacing: 10) {
sectionLabel("SERVER")
Card {
cardRow(icon: "server.rack") {
fieldStack(title: "Direct (LAN) host",
text: $connectivity.directHost,
placeholder: "192.168.30.50:8080",
reach: directReach)
}
}
}
}
// MARK: - Remote access
private var remoteAccessSection: some View {
VStack(alignment: .leading, spacing: 10) {
sectionLabel("REMOTE ACCESS")
Card {
VStack(spacing: 0) {
cardRow(icon: "globe.badge.chevron.backward") {
Toggle(isOn: $connectivity.remoteEnabled) {
VStack(alignment: .leading, spacing: 3) {
Text("Use \(providerName) when remote")
.font(.system(size: 16, weight: .heavy))
.foregroundStyle(.white)
Text("Connects via VPN when not on home network")
.font(.system(size: 12))
.foregroundStyle(Color(hex: "888888"))
.fixedSize(horizontal: false, vertical: true)
}
}
.tint(Palette.orange)
}
if connectivity.remoteEnabled {
divider
cardRow(icon: "externaldrive.connected.to.line.below") {
fieldStack(title: "\(providerName) host",
text: $connectivity.remoteHost,
placeholder: connectivity.provider.remoteHint,
reach: remoteReach)
}
divider
statusRow
}
}
}
}
}
private var statusRow: some View {
HStack(spacing: 10) {
dot(for: connectivity.remoteEnabled ? remoteReach : .unknown)
Text(statusText)
.font(.system(size: 14, weight: .bold))
.foregroundStyle(statusColor)
Spacer()
if remoteReach != .reachable && connectivity.provider.canOpenApp {
Button { connectivity.provider.openApp() } label: {
Text("Open \(providerName)")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, 14)
.frame(height: 36)
.background(Palette.orange)
.clipShape(Capsule())
}
}
}
.padding(.vertical, 12)
.padding(.horizontal, 14)
}
private var statusText: String {
switch remoteReach {
case .reachable: return "\(providerName) connected"
case .checking: return "Checking \(providerName)"
default: return "\(providerName) not active"
}
}
private var statusColor: Color {
switch remoteReach {
case .reachable: return Color(hex: "33C25E")
case .checking: return Palette.healthFailing
default: return Color(hex: "888888")
}
}
// MARK: - Automation
private var automationSection: some View {
VStack(alignment: .leading, spacing: 10) {
sectionLabel("AUTOMATION")
Card {
VStack(spacing: 0) {
cardRow(icon: "wifi") {
Toggle(isOn: $connectivity.autoConnect) {
VStack(alignment: .leading, spacing: 3) {
Text("Auto-connect on network change")
.font(.system(size: 16, weight: .heavy))
.foregroundStyle(.white)
Text("Prefers the LAN on a trusted network; switches to \(providerName) when away")
.font(.system(size: 12))
.foregroundStyle(Color(hex: "888888"))
.fixedSize(horizontal: false, vertical: true)
}
}
.tint(Palette.orange)
}
if connectivity.autoConnect {
divider
cardRow(icon: "timer") {
HStack {
Text("Wait before switching")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(.white)
Spacer()
Stepper(value: $connectivity.switchDelaySeconds, in: 1...60, step: 1) {
Text("\(connectivity.switchDelaySeconds)s")
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundStyle(Palette.orange)
}
.labelsHidden()
.fixedSize()
Text("\(connectivity.switchDelaySeconds)s")
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundStyle(Palette.orange)
.frame(width: 36, alignment: .trailing)
}
}
}
}
}
Text("iOS can't start the \(providerName) tunnel for Jarvis — keep the \(providerName) app connected when away. Jarvis only picks the reachable address.")
.font(.system(size: 11))
.foregroundStyle(Color(hex: "666666"))
.lineSpacing(2)
}
}
// MARK: - Provider
private var providerSection: some View {
VStack(alignment: .leading, spacing: 10) {
sectionLabel("PROVIDER")
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(VPNProvider.allCases) { p in
let selected = connectivity.provider == p
Button { connectivity.provider = p } label: {
Text(p.displayName)
.font(.system(size: 13, weight: .bold))
.foregroundStyle(selected ? .black : Color(hex: "AAAAAA"))
.padding(.horizontal, 14)
.frame(height: 34)
.background(selected ? Palette.orange : Palette.surface)
.clipShape(Capsule())
}
}
}
}
}
}
// MARK: - Actions
private var actions: some View {
HStack(spacing: 12) {
Button { Task { await recheck() } } label: {
Text("Re-check")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity).frame(height: 50)
.background(Palette.surface2)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
Button { Task { await manager.resolveAndActivate(); await recheck() } } label: {
Text("Apply")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(.black)
.frame(maxWidth: .infinity).frame(height: 50)
.background(connectivity.isConfigured ? Palette.orange : Palette.surface2)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(!connectivity.isConfigured)
}
}
// MARK: - Building blocks
private func sectionLabel(_ t: String) -> some View {
Text(t)
.font(.system(size: 11, weight: .bold, design: .monospaced))
.kerning(0.8)
.foregroundStyle(Color(hex: "555555"))
}
private func Card<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
content()
.background(Palette.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
private func cardRow<Content: View>(icon: String, @ViewBuilder _ content: () -> Content) -> some View {
HStack(alignment: .center, spacing: 14) {
Image(systemName: icon)
.font(.system(size: 18, weight: .regular))
.foregroundStyle(Color(hex: "999999"))
.frame(width: 26)
content()
}
.padding(.horizontal, 14)
.padding(.vertical, 14)
.frame(minHeight: 44)
}
private var divider: some View {
Rectangle().fill(Palette.surface2).frame(height: 1).padding(.leading, 54)
}
private func fieldStack(title: String, text: Binding<String>, placeholder: String, reach: Reachability) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.system(size: 12))
.foregroundStyle(Color(hex: "888888"))
HStack(spacing: 8) {
TextField("", text: text,
prompt: Text(placeholder).foregroundColor(Color(hex: "444444")))
.font(.system(size: 16, weight: .regular, design: .monospaced))
.foregroundStyle(Palette.orange)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.keyboardType(.URL)
if reach != .unknown { dot(for: reach) }
}
}
}
// MARK: - Reachability helpers
private func recheck() async {
await manager.check(connectivity.directHost)
if connectivity.remoteActiveInPolicy {
await manager.check(connectivity.remoteHost)
}
}
private func color(for r: Reachability) -> Color {
switch r {
case .reachable: return Color(hex: "33C25E")
case .unreachable: return Color(hex: "C25555")
case .checking: return Palette.healthFailing
case .unknown: return Color(hex: "555555")
}
}
private func dot(for r: Reachability) -> some View {
Circle().fill(color(for: r)).frame(width: 9, height: 9)
}
}

View File

@@ -0,0 +1,114 @@
// AddFeedSheet.swift
// Jarvis add a new RSS feed: URL + name + confirm.
import SwiftUI
struct AddFeedSheet: View {
/// Returns true on success.
let onAdd: (String, String) async -> Bool
@Environment(\.dismiss) private var dismiss
@State private var url = ""
@State private var name = ""
@State private var isSubmitting = false
@State private var errorMessage: String?
private var canSubmit: Bool {
!url.trimmingCharacters(in: .whitespaces).isEmpty &&
!name.trimmingCharacters(in: .whitespaces).isEmpty &&
!isSubmitting
}
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Add feed")
.font(.system(size: 22, weight: .heavy))
.foregroundStyle(.white)
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(Color(hex: "888888"))
.frame(width: 44, height: 44)
}
}
.padding(.top, 8)
field(label: "FEED URL", text: $url,
placeholder: "https://example.com/rss",
mono: true, keyboard: .URL)
.padding(.top, 12)
field(label: "DISPLAY NAME", text: $name,
placeholder: "Example News",
mono: false, keyboard: .default)
.padding(.top, 16)
if let errorMessage {
Text(errorMessage)
.font(.system(size: 13))
.foregroundStyle(Color(hex: "C25555"))
.padding(.top, 14)
}
Button {
Task { await submit() }
} label: {
HStack {
Spacer()
if isSubmitting {
ProgressView().tint(.black)
} else {
Text("Add feed").font(.system(size: 16, weight: .bold)).foregroundStyle(.black)
}
Spacer()
}
.frame(height: 52)
.background(canSubmit ? Palette.orange : Palette.surface2)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(!canSubmit)
.padding(.top, 28)
Spacer()
}
.padding(.horizontal, 20)
}
.preferredColorScheme(.dark)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
private func field(label: String, text: Binding<String>, placeholder: String,
mono: Bool, keyboard: UIKeyboardType) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(label)
.font(.system(size: 10, weight: .bold, design: .monospaced))
.kerning(0.8)
.foregroundStyle(Color(hex: "666666"))
TextField("", text: text,
prompt: Text(placeholder).foregroundColor(Color(hex: "444444")))
.font(.system(size: 15, weight: .regular, design: mono ? .monospaced : .default))
.foregroundStyle(mono ? Palette.orange : .white)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.keyboardType(keyboard)
.padding(14)
.background(Palette.surface)
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Palette.surface2, lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
private func submit() async {
isSubmitting = true
errorMessage = nil
let ok = await onAdd(url.trimmingCharacters(in: .whitespaces),
name.trimmingCharacters(in: .whitespaces))
isSubmitting = false
if ok { dismiss() } else { errorMessage = "Couldnt add feed. Check the URL and try again." }
}
}

View File

@@ -0,0 +1,329 @@
// FeedManagerView.swift
// Jarvis feed health & management. Healthy vs. needs-attention, live health
// updates over WebSocket, swipe to delete, add-feed sheet.
import SwiftUI
@MainActor
final class FeedManagerViewModel: ObservableObject {
@Published var feeds: [Feed] = []
@Published var isLoading = false
@Published var error: String?
private let api = APIClient.shared
func load() async {
isLoading = true
error = nil
do {
feeds = try await api.fetchFeeds()
} catch let e as APIError {
error = e.errorDescription
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func add(url: String, name: String) async -> Bool {
do {
let feed = try await api.addFeed(url: url, name: name)
feeds.append(feed)
return true
} catch let e as APIError {
error = e.errorDescription
return false
} catch {
self.error = error.localizedDescription
return false
}
}
func delete(_ feed: Feed) async {
// Optimistic removal; restore on failure.
let snapshot = feeds
feeds.removeAll { $0.id == feed.id }
do {
try await api.deleteFeed(id: feed.id)
} catch {
feeds = snapshot
self.error = (error as? APIError)?.errorDescription ?? error.localizedDescription
}
}
/// Apply a live `feed.health` WebSocket event.
func applyHealth(_ event: WSEvent) {
guard let id = event.feedId, let health = event.health,
let idx = feeds.firstIndex(where: { $0.id == id }) else { return }
let f = feeds[idx]
feeds[idx] = Feed(
id: f.id, name: f.name, url: f.url, health: health,
pollIntervalSeconds: f.pollIntervalSeconds,
failureCount: event.failureCount ?? f.failureCount,
lastFetchedAt: f.lastFetchedAt, articleCountToday: f.articleCountToday
)
}
}
struct FeedManagerView: View {
@EnvironmentObject var ws: WebSocketManager
@EnvironmentObject var settings: ServerSettings
@StateObject private var vm = FeedManagerViewModel()
@State private var query = ""
@State private var showAdd = false
@State private var showConnectivity = false
private var filtered: [Feed] {
guard !query.isEmpty else { return vm.feeds }
return vm.feeds.filter {
$0.name.localizedCaseInsensitiveContains(query) ||
$0.url.localizedCaseInsensitiveContains(query)
}
}
private var healthy: [Feed] { filtered.filter { $0.health == .active } }
private var attention: [Feed] { filtered.filter { $0.health != .active } }
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 0) {
header
serverCard
searchBar
feedList
}
}
.preferredColorScheme(.dark)
.presentationDragIndicator(.visible)
.sheet(isPresented: $showAdd) {
AddFeedSheet { url, name in
await vm.add(url: url, name: name)
}
}
.sheet(isPresented: $showConnectivity) {
ConnectivityView()
}
.task { await vm.load() }
.onReceive(NotificationCenter.default.publisher(for: .feedHealthChanged)) { note in
if let event = note.object as? WSEvent { vm.applyHealth(event) }
}
}
// MARK: - Header
private var header: some View {
HStack(alignment: .center) {
JarvisWordmark(size: 26)
Spacer()
Button { showAdd = true } label: {
HStack(spacing: 5) {
Image(systemName: "plus").font(.system(size: 13, weight: .heavy))
Text("Add feed").font(.system(size: 14, weight: .bold))
}
.foregroundStyle(.black)
.padding(.horizontal, 14)
.frame(height: 36)
.background(Palette.orange)
.clipShape(Capsule())
}
}
.padding(.horizontal, 16)
.padding(.top, 20)
.padding(.bottom, 14)
}
// MARK: - Platform connection card
private var serverCard: some View {
Button { showConnectivity = true } label: {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("PLATFORM")
.font(.system(size: 9, weight: .bold, design: .monospaced))
.kerning(0.8)
.foregroundStyle(Color(hex: "555555"))
Text(settings.host ?? "not configured")
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundStyle(Palette.orange)
}
Spacer()
HStack(spacing: 6) {
Circle().fill(ws.connectionState.dotColor).frame(width: 7, height: 7)
Text(ws.connectionState.label.uppercased())
.font(.system(size: 10, weight: .bold))
.kerning(0.6)
.foregroundStyle(ws.connectionState.isLive ? Color(hex: "33C25E") : Color(hex: "888888"))
}
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(Color(hex: "555555"))
}
.padding(14)
.background(Palette.surface)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
// MARK: - Search
private var searchBar: some View {
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.font(.system(size: 14))
.foregroundStyle(Color(hex: "555555"))
TextField("", text: $query, prompt: Text("Search feeds").foregroundColor(Color(hex: "555555")))
.font(.system(size: 15, weight: .regular))
.foregroundStyle(.white)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
.padding(.horizontal, 14)
.frame(height: 44)
.background(Palette.surface2)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
// MARK: - List
private var feedList: some View {
List {
if vm.isLoading && vm.feeds.isEmpty {
loadingRow
}
if !healthy.isEmpty {
section(title: "HEALTHY", feeds: healthy)
}
if !attention.isEmpty {
section(title: "NEEDS ATTENTION", feeds: attention)
}
if !vm.isLoading && filtered.isEmpty {
emptyRow
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.black)
.refreshable { await vm.load() }
}
private func section(title: String, feeds: [Feed]) -> some View {
Section {
ForEach(feeds) { feed in
FeedRowView(feed: feed)
.listRowBackground(Color.black)
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
.listRowSeparatorTint(Palette.hairline)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
Task { await vm.delete(feed) }
} label: {
Label("Delete", systemImage: "trash")
}
}
}
} header: {
Text(title)
.font(.system(size: 10, weight: .bold, design: .monospaced))
.kerning(0.8)
.foregroundStyle(Color(hex: "555555"))
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 6, trailing: 16))
}
}
private var loadingRow: some View {
ProgressView().tint(Palette.orange)
.frame(maxWidth: .infinity)
.listRowBackground(Color.black)
.listRowSeparator(.hidden)
}
private var emptyRow: some View {
Text(query.isEmpty ? (vm.error ?? "No feeds configured.") : "No feeds match “\(query)”.")
.font(.system(size: 13))
.foregroundStyle(Color(hex: "555555"))
.frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(Color.black)
.listRowSeparator(.hidden)
}
}
// MARK: - Feed row
struct FeedRowView: View {
let feed: Feed
var body: some View {
HStack(spacing: 12) {
Image(systemName: "dot.radiowaves.up.forward")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Color(hex: "777777"))
.frame(width: 24)
VStack(alignment: .leading, spacing: 4) {
Text(feed.name)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(.white)
.lineLimit(1)
Text(metaLine)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(Color(hex: "666666"))
.lineLimit(1)
}
Spacer(minLength: 8)
healthDot
}
.frame(minHeight: 44)
.contentShape(Rectangle())
}
private var metaLine: String {
let interval = pollText(feed.pollIntervalSeconds)
let base = "Polls \(interval) · \(feed.articleCountToday) today"
if feed.health == .failing {
return base + " · ⚠ \(retryText)"
}
if feed.health == .dead {
return base + " · dead"
}
return base
}
private var retryText: String {
guard let last = feed.lastFetchedAt else { return "retrying" }
let next = last.addingTimeInterval(Double(feed.pollIntervalSeconds))
let secs = Int(next.timeIntervalSinceNow)
return secs > 0 ? "retry \(secs)s" : "retrying…"
}
@ViewBuilder
private var healthDot: some View {
// Live-updating countdown for failing feeds.
if feed.health == .failing {
TimelineView(.periodic(from: .now, by: 1)) { _ in
dot(color: Palette.healthFailing)
}
} else {
dot(color: feed.health == .active ? Palette.healthActive : Palette.healthDead)
}
}
private func dot(color: Color) -> some View {
Circle()
.fill(color)
.frame(width: 10, height: 10)
.overlay(Circle().stroke(color.opacity(0.9), lineWidth: 3).blur(radius: 2))
}
private func pollText(_ secs: Int) -> String {
if secs % 3600 == 0 { return "every \(secs / 3600) hr" }
if secs >= 60 { return "every \(secs / 60) min" }
return "every \(secs) s"
}
}

View 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
)
}
}

View 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)
}

View File

@@ -0,0 +1,128 @@
// OnboardingView.swift
import SwiftUI
struct OnboardingView: View {
@EnvironmentObject var settings: ServerSettings
@EnvironmentObject var connectivity: ConnectivitySettings
@State private var host = ""
@State private var isConnecting = false
@State private var errorMessage: String?
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack(alignment: .leading, spacing: 0) {
Spacer().frame(height: 48)
// Wordmark
Group {
Text("J").foregroundColor(Color("KisaniOrange")) +
Text("arvis").foregroundColor(.white)
}
.font(.system(size: 48, weight: .black, design: .default))
.kerning(-2)
Text("Point Jarvis at your platform\nto start receiving signals.")
.font(.system(size: 16))
.foregroundColor(Color(.systemGray))
.lineSpacing(4)
.padding(.top, 10)
.padding(.bottom, 40)
// Field label
Text("Server address")
.font(.system(size: 11, weight: .bold))
.foregroundColor(Color(.systemGray2))
.kerning(0.8)
.textCase(.uppercase)
.padding(.bottom, 8)
// Input field
HStack(spacing: 12) {
Image(systemName: "server.rack")
.font(.system(size: 18))
.foregroundColor(host.isEmpty ? Color(.systemGray3) : Color("KisaniOrange"))
TextField("192.168.1.42:8080", text: $host)
.font(.system(size: 15, design: .monospaced))
.foregroundColor(Color("KisaniOrange"))
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.keyboardType(.URL)
}
.padding(14)
.background(host.isEmpty ? Color(.systemGray6).opacity(0.1) : Color("KisaniOrange").opacity(0.07))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(host.isEmpty ? Color(.systemGray5) : Color("KisaniOrange"), lineWidth: 0.5)
)
.cornerRadius(12)
Text("http:// or https:// · local network or VPN")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(Color(.systemGray4))
.padding(.top, 8)
.padding(.bottom, 32)
if let errorMessage {
Text(errorMessage)
.font(.system(size: 13))
.foregroundColor(.red.opacity(0.8))
.padding(.bottom, 12)
}
// Connect button
Button {
Task { await connect() }
} label: {
HStack {
Spacer()
if isConnecting {
ProgressView().tint(.white)
} else {
Text("Connect to Jarvis")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
}
Spacer()
}
.frame(height: 52)
.background(host.isEmpty ? Color(.systemGray5) : Color("KisaniOrange"))
.cornerRadius(14)
}
.disabled(host.isEmpty || isConnecting)
Button("Scan QR code instead") {}
.font(.system(size: 14))
.foregroundColor(Color(.systemGray3))
.frame(maxWidth: .infinity)
.frame(height: 48)
Spacer()
Text("Self-hosted · no account needed")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(Color(.systemGray5))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.bottom, 24)
}
.padding(.horizontal, 24)
}
}
private func connect() async {
isConnecting = true
errorMessage = nil
do {
try await settings.save(host: host)
// Seed the Direct/LAN endpoint so connectivity can fail over to a VPN later.
if connectivity.directHost.trimmingCharacters(in: .whitespaces).isEmpty {
connectivity.directHost = host.trimmingCharacters(in: .whitespaces)
}
} catch {
errorMessage = error.localizedDescription
}
isConnecting = false
}
}

View File

@@ -0,0 +1,250 @@
// ArticleReaderView.swift
// Jarvis full article. Caches to SwiftData on load; reads from cache offline.
import SwiftUI
import SwiftData
@MainActor
final class ArticleReaderViewModel: ObservableObject {
@Published var article: Article?
@Published var siblings: [TimelineEntry] = []
@Published var isLoading = false
@Published var error: String?
private let api = APIClient.shared
func load(route: ArticleRoute, context: ModelContext) async {
isLoading = true
error = nil
do {
let art = try await api.fetchArticle(id: route.articleId)
article = art
cache(art, context: context)
if let detail = try? await api.fetchStory(id: route.storyId) {
siblings = detail.timeline
.filter { $0.articleId != route.articleId }
.sorted { $0.publishedAt < $1.publishedAt }
}
} catch {
// Offline: fall back to the SwiftData cache.
loadCached(route: route, context: context)
if article == nil {
self.error = (error as? APIError)?.errorDescription ?? error.localizedDescription
}
}
isLoading = false
}
private func cache(_ art: Article, context: ModelContext) {
let id = art.id
let existing = try? context.fetch(
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id })
)
if existing?.first == nil {
context.insert(CachedArticle(from: art))
try? context.save()
}
}
private func loadCached(route: ArticleRoute, context: ModelContext) {
let id = route.articleId
if let cached = (try? context.fetch(
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id })
))?.first {
article = Article(cached: cached)
}
let sid = route.storyId
let arts = (try? context.fetch(
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.storyId == sid })
)) ?? []
siblings = arts
.filter { $0.id != route.articleId }
.map { TimelineEntry(articleId: $0.id, source: $0.source, headline: $0.headline,
publishedAt: $0.publishedAt, isBreaking: false) }
}
}
struct ArticleReaderView: View {
let route: ArticleRoute
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@StateObject private var vm = ArticleReaderViewModel()
@Query private var cachedArticles: [CachedArticle]
private var article: Article? { vm.article }
private var isCached: Bool { cachedArticles.contains { $0.id == route.articleId } }
private var backTitle: String {
let h = route.parentHeadline
return h.count > 30 ? String(h.prefix(30)).trimmingCharacters(in: .whitespaces) + "" : h
}
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
ScrollView {
if let article {
content(article)
} else if vm.isLoading {
ProgressView().tint(Palette.orange).padding(.top, 80)
} else {
Text(vm.error ?? "Article unavailable.")
.font(.system(size: 14))
.foregroundStyle(Color(hex: "555555"))
.padding(.top, 80)
}
}
}
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .topBarLeading) { backButton }
}
.toolbarBackground(Color.black, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.preferredColorScheme(.dark)
.task { await vm.load(route: route, context: modelContext) }
}
private var backButton: some View {
Button { dismiss() } label: {
HStack(spacing: 4) {
Image(systemName: "chevron.left").font(.system(size: 15, weight: .bold))
Text(backTitle).font(.system(size: 16, weight: .regular)).lineLimit(1)
}
.foregroundStyle(Palette.orange)
}
}
@ViewBuilder
private func content(_ article: Article) -> some View {
VStack(alignment: .leading, spacing: 16) {
// Source pill + timestamp + cached badge
HStack(spacing: 10) {
Text(article.source)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 10).padding(.vertical, 5)
.background(Palette.orange)
.clipShape(Capsule())
Text(article.publishedAt.clockShort)
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(Color(hex: "666666"))
if isCached { CachedBadge(text: "Cached") }
Spacer()
}
Text(article.headline)
.font(.system(size: 26, weight: .heavy))
.foregroundStyle(.white)
.fixedSize(horizontal: false, vertical: true)
heroImage(article.imageUrl)
if let author = article.author, !author.isEmpty {
Text("By \(author)")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(Color(hex: "777777"))
}
Text(article.body)
.font(.system(size: 16, weight: .regular))
.foregroundStyle(Palette.bodyText) // #4A4A4A
.lineSpacing(10) // ~1.65 line-height at 16pt
.fixedSize(horizontal: false, vertical: true)
if !vm.siblings.isEmpty {
Divider().overlay(Palette.hairline).padding(.vertical, 8)
clusterSection
}
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
}
private func heroImage(_ urlString: String?) -> some View {
let fallback = RoundedRectangle(cornerRadius: 10).fill(Palette.surface2)
return Group {
if let urlString, let url = URL(string: urlString) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().aspectRatio(contentMode: .fill)
case .empty:
fallback.overlay(ProgressView().tint(Color(hex: "555555")))
case .failure:
fallback.overlay(Image(systemName: "photo")
.font(.system(size: 26)).foregroundStyle(Color(hex: "3A3A3A")))
@unknown default:
fallback
}
}
} else {
fallback.overlay(Image(systemName: "photo")
.font(.system(size: 26)).foregroundStyle(Color(hex: "3A3A3A")))
}
}
.frame(height: 200)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
private var clusterSection: some View {
VStack(alignment: .leading, spacing: 0) {
Text("MORE FROM THIS CLUSTER")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.kerning(0.8)
.foregroundStyle(Color(hex: "555555"))
.padding(.bottom, 12)
ForEach(vm.siblings.prefix(3)) { entry in
NavigationLink(value: ArticleRoute(articleId: entry.articleId,
storyId: route.storyId,
parentHeadline: route.parentHeadline)) {
clusterRow(entry)
}
.buttonStyle(.plain)
}
}
}
private func clusterRow(_ entry: TimelineEntry) -> some View {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(entry.source)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(Palette.orange)
Spacer()
Text(entry.publishedAt.clockShort)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(Color(hex: "555555"))
}
Text(entry.headline)
.font(.system(size: 15, weight: .regular))
.foregroundStyle(Color(hex: "CFCFCF"))
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
}
.padding(.vertical, 12)
.overlay(alignment: .bottom) { Rectangle().fill(Palette.hairline).frame(height: 0.5) }
.contentShape(Rectangle())
}
}
// Reconstruct an Article from its cached copy for offline display.
extension Article {
init(cached: CachedArticle) {
self.init(
id: cached.id,
storyId: cached.storyId,
source: cached.source,
sourceUrl: "",
headline: cached.headline,
body: cached.body,
imageUrl: cached.imageUrl,
author: cached.author,
publishedAt: cached.publishedAt
)
}
}

View File

@@ -0,0 +1,34 @@
// RootTabView.swift
import SwiftUI
struct RootTabView: View {
@EnvironmentObject var store: StoryStore
var body: some View {
TabView {
SignalFeedView()
.tabItem {
Label("Today", systemImage: "square.grid.2x2")
}
LatestView()
.tabItem {
Label("Latest", systemImage: "newspaper")
}
SavedView()
.tabItem {
Label("Saved", systemImage: "bookmark")
}
SearchView()
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
}
.tint(Color("KisaniOrange"))
.preferredColorScheme(.dark)
.task { await store.loadStories(refresh: true) }
}
}

View File

@@ -0,0 +1,178 @@
// SecondaryTabs.swift
// Jarvis Latest / Saved / Search. Required by RootTabView; they reuse the
// signal-feed components rather than introducing new surfaces.
import SwiftUI
import SwiftData
// Shared destinations so taps work inside each tab's own NavigationStack.
private extension View {
func jarvisDestinations() -> some View {
self
.navigationDestination(for: StorySummary.self) { StoryDetailView(story: $0) }
.navigationDestination(for: ArticleRoute.self) { ArticleReaderView(route: $0) }
}
}
private struct TabHeader: View {
let title: String
var body: some View {
HStack {
JarvisWordmark(size: 26)
Text(title)
.font(.system(size: 13, weight: .bold))
.foregroundStyle(Color(hex: "666666"))
.padding(.top, 6)
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 12)
}
}
private struct StoryList: View {
let stories: [StorySummary]
let cachedIds: Set<String>
var body: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(stories) { story in
NavigationLink(value: story) {
StoryRowView(story: story, isCached: cachedIds.contains(story.id))
}
.buttonStyle(.plain)
}
}
}
}
}
// MARK: - Latest (most recently formed stories first)
struct LatestView: View {
@EnvironmentObject var store: StoryStore
@Query private var cachedArticles: [CachedArticle]
private var latest: [StorySummary] {
store.stories.sorted { $0.createdAt > $1.createdAt }
}
var body: some View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 0) {
TabHeader(title: "Latest")
Divider().overlay(Palette.hairline)
StoryList(stories: latest, cachedIds: Set(cachedArticles.map(\.storyId)))
}
}
.navigationBarHidden(true)
.jarvisDestinations()
}
.preferredColorScheme(.dark)
}
}
// MARK: - Saved (offline cache)
struct SavedView: View {
@Query private var cachedStories: [CachedStory]
@Query private var cachedArticles: [CachedArticle]
private var stories: [StorySummary] {
cachedStories.map(StorySummary.init(cached:)).sorted { $0.signalScore > $1.signalScore }
}
var body: some View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 0) {
TabHeader(title: "Saved · offline")
Divider().overlay(Palette.hairline)
if stories.isEmpty {
VStack(spacing: 12) {
Image(systemName: "bookmark")
.font(.system(size: 28)).foregroundStyle(Color(hex: "333333"))
Text("Nothing cached yet")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(Color(hex: "555555"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
StoryList(stories: stories, cachedIds: Set(cachedArticles.map(\.storyId)))
}
}
}
.navigationBarHidden(true)
.jarvisDestinations()
}
.preferredColorScheme(.dark)
}
}
// MARK: - Search
struct SearchView: View {
@EnvironmentObject var store: StoryStore
@Query private var cachedArticles: [CachedArticle]
@State private var query = ""
private var results: [StorySummary] {
guard !query.isEmpty else { return [] }
return store.stories
.filter { $0.headline.localizedCaseInsensitiveContains(query)
|| $0.summary.localizedCaseInsensitiveContains(query) }
.sorted { $0.signalScore > $1.signalScore }
}
var body: some View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 0) {
TabHeader(title: "Search")
searchBar
Divider().overlay(Palette.hairline)
if query.isEmpty {
placeholder("Search stories by headline")
} else if results.isEmpty {
placeholder("No matches for “\(query)")
} else {
StoryList(stories: results, cachedIds: Set(cachedArticles.map(\.storyId)))
}
}
}
.navigationBarHidden(true)
.jarvisDestinations()
}
.preferredColorScheme(.dark)
}
private var searchBar: some View {
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.font(.system(size: 14)).foregroundStyle(Color(hex: "555555"))
TextField("", text: $query,
prompt: Text("Search").foregroundColor(Color(hex: "555555")))
.font(.system(size: 15))
.foregroundStyle(.white)
.autocorrectionDisabled()
}
.padding(.horizontal, 14)
.frame(height: 44)
.background(Palette.surface2)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
.padding(.bottom, 10)
}
private func placeholder(_ text: String) -> some View {
Text(text)
.font(.system(size: 14, weight: .bold))
.foregroundStyle(Color(hex: "555555"))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

View File

@@ -0,0 +1,43 @@
// CachedBadge.swift
// Jarvis offline-cache indicators (a bare dot, and a labelled green badge).
import SwiftUI
/// Tiny green dot used in the signal feed rows when a story is cached offline.
struct CachedDot: View {
var size: CGFloat = 7
var body: some View {
Circle()
.fill(Palette.cachedGreen)
.frame(width: size, height: size)
.overlay(Circle().stroke(Color(hex: "3E7E3E"), lineWidth: 0.5))
}
}
/// Labelled green badge "Cached", or "All N articles cached · available offline".
struct CachedBadge: View {
let text: String
var body: some View {
HStack(spacing: 6) {
CachedDot(size: 6)
Text(text)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(Color(hex: "6FBF6F"))
}
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(Palette.cachedGreen.opacity(0.18))
.clipShape(Capsule())
}
}
#Preview {
VStack(spacing: 16) {
CachedDot()
CachedBadge(text: "Cached")
CachedBadge(text: "All 7 articles cached · available offline")
}
.padding()
.background(Color.black)
}

View File

@@ -0,0 +1,60 @@
// ConnectionBanner.swift
// Jarvis Live / Offline / Reconnecting indicator driven by WebSocketManager.
import SwiftUI
extension ConnectionState {
var isLive: Bool {
if case .connected = self { return true }
return false
}
var label: String {
switch self {
case .connected: return "Live"
case .connecting: return "Connecting"
case .reconnecting: return "Reconnecting"
case .disconnected: return "Offline"
}
}
var dotColor: Color {
switch self {
case .connected: return Color(hex: "33C25E")
case .connecting,
.reconnecting: return Palette.healthFailing
case .disconnected: return Color(hex: "555555")
}
}
}
/// Compact Live/Offline pill a colored dot + state label.
struct ConnectionBanner: View {
let state: ConnectionState
var body: some View {
HStack(spacing: 6) {
Circle()
.fill(state.dotColor)
.frame(width: 7, height: 7)
Text(state.label.uppercased())
.font(.system(size: 11, weight: .bold))
.kerning(0.6)
.foregroundStyle(state.isLive ? Color(hex: "33C25E") : Color(hex: "888888"))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Palette.surface)
.clipShape(Capsule())
}
}
#Preview {
VStack(spacing: 12) {
ConnectionBanner(state: .connected)
ConnectionBanner(state: .reconnecting(attempt: 2))
ConnectionBanner(state: .disconnected)
}
.padding()
.background(Color.black)
}

View File

@@ -0,0 +1,31 @@
// SignalStripe.swift
// Jarvis the 3pt left-edge stripe whose color is tied to the signal score.
import SwiftUI
struct SignalStripe: View {
let score: Int
var width: CGFloat = 3
var body: some View {
Rectangle()
.fill(Signal.stripeColor(score))
.frame(width: width)
}
}
#Preview {
HStack(spacing: 24) {
ForEach([97, 78, 55, 35, 12, 0], id: \.self) { s in
VStack {
SignalStripe(score: s)
.frame(height: 60)
Text("\(s)")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Signal.scoreColor(s))
}
}
}
.padding()
.background(Color.black)
}

View File

@@ -0,0 +1,42 @@
// SourceChips.swift
// Jarvis names the first 2 sources, collapses the rest into "+N".
import SwiftUI
struct SourceChips: View {
/// Source names in display order.
let names: [String]
/// Total number of sources (may exceed `names.count`).
let total: Int
var maxNamed: Int = 2
private var named: [String] { Array(names.prefix(maxNamed)) }
private var overflow: Int { max(0, total - named.count) }
var body: some View {
HStack(spacing: 6) {
ForEach(named, id: \.self) { name in
chip(name)
}
if overflow > 0 {
chip("+\(overflow)", muted: true)
}
}
}
private func chip(_ text: String, muted: Bool = false) -> some View {
Text(text)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(muted ? Color(hex: "777777") : Color(hex: "CCCCCC"))
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(Palette.surface2)
.clipShape(Capsule())
}
}
#Preview {
SourceChips(names: ["Daily Monitor", "NilePost", "The EastAfrican"], total: 7)
.padding()
.background(Color.black)
}

View File

@@ -0,0 +1,162 @@
// Theme.swift
// Jarvis shared design tokens, signal-score fade math, formatting, nav routes.
// Every hex value here is from the spec; nothing is invented.
import SwiftUI
// MARK: - Hex colors
extension Color {
init(hex: String) {
let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
var v: UInt64 = 0
Scanner(string: s).scanHexInt64(&v)
let r, g, b: Double
switch s.count {
case 6:
r = Double((v >> 16) & 0xFF) / 255
g = Double((v >> 8) & 0xFF) / 255
b = Double(v & 0xFF) / 255
default:
r = 0; g = 0; b = 0
}
self = Color(.sRGB, red: r, green: g, blue: b, opacity: 1)
}
}
// MARK: - Palette
enum Palette {
static let orange = Color("KisaniOrange") // #FF5C00
static let black = Color.black // #000000
static let surface = Color(hex: "111111")
static let surface2 = Color(hex: "1A1A1A")
static let hairline = Color(hex: "1A1A1A")
// health
static let healthActive = Color(hex: "2A5A2A")
static let healthFailing = Color(hex: "AA6600")
static let healthDead = Color(hex: "5A1A1A")
// blocks
static let consensusBorder = Color(hex: "FF5C00")
static let consensusFill = Color(hex: "1F1206") // dark orange
static let conflictBorder = Color(hex: "5A1A1A") // dark red
static let conflictFill = Color(hex: "1A0C0C") // dark red
static let cachedGreen = Color(hex: "2A5A2A")
static let bodyText = Color(hex: "4A4A4A")
}
// MARK: - Signal-score fade
//
// Spec anchors:
// stripe : 97 #FF5C00, fades to #1A1A1A at 0
// title : 97 white, 20 #444444, 019 near invisible
// score : 80100 full orange · 5079 dimmed orange · 2049 dark brown · 019 near invisible
enum Signal {
private static func lerp(_ a: (Double, Double, Double),
_ b: (Double, Double, Double),
_ t: Double) -> Color {
let t = max(0, min(1, t))
return Color(.sRGB,
red: a.0 + (b.0 - a.0) * t,
green: a.1 + (b.1 - a.1) * t,
blue: a.2 + (b.2 - a.2) * t,
opacity: 1)
}
/// 3pt left stripe color: #1A1A1A (0) #FF5C00 (97+).
static func stripeColor(_ score: Int) -> Color {
let t = Double(score) / 97.0
return lerp((0x1A/255, 0x1A/255, 0x1A/255), // #1A1A1A
(0xFF/255, 0x5C/255, 0x00/255), // #FF5C00
t)
}
/// Story title color: white (97+) #444444 (20) near invisible (0).
static func titleColor(_ score: Int) -> Color {
if score >= 97 { return .white }
if score >= 20 {
let t = Double(score - 20) / Double(97 - 20)
return lerp((0x44/255, 0x44/255, 0x44/255), // #444444
(1, 1, 1), // white
t)
}
// 019 near invisible: #1C1C1C #444444
let t = Double(score) / 20.0
return lerp((0x1C/255, 0x1C/255, 0x1C/255),
(0x44/255, 0x44/255, 0x44/255),
t)
}
/// Signal score number color: stays in orange/brown hue, fades with score.
/// near-invisible brown (0) full #FF5C00 (100).
static func scoreColor(_ score: Int) -> Color {
let t = Double(score) / 100.0
return lerp((0x2A/255, 0x18/255, 0x10/255), // near-invisible brown
(0xFF/255, 0x5C/255, 0x00/255), // #FF5C00
t)
}
}
// MARK: - Topic display
enum Topic {
static func label(_ slug: String) -> String {
switch slug.lowercased() {
case "finance": return "Finance"
case "tech": return "Tech"
case "politics": return "Politics"
case "africa": return "Africa"
default: return slug.prefix(1).uppercased() + slug.dropFirst()
}
}
/// Topic pills shown on the home screen.
static let filters: [(label: String, slug: String?)] = [
("All", nil),
("Finance", "finance"),
("Tech", "tech"),
("Politics", "politics"),
("Africa", "africa"),
]
}
// MARK: - Time formatting
extension Date {
/// Short relative string e.g. "just now", "3 min", "2 hr", "4 d".
func timeAgoShort(reference: Date = Date()) -> String {
let secs = max(0, Int(reference.timeIntervalSince(self)))
switch secs {
case 0..<60: return "just now"
case 60..<3600: return "\(secs / 60) min"
case 3600..<86400: return "\(secs / 3600) hr"
default: return "\(secs / 86400) d"
}
}
/// "08:03" style monospaced clock for timeline / source chips.
var clockShort: String {
let f = DateFormatter()
f.dateFormat = "HH:mm"
return f.string(from: self)
}
}
func syncedMinutesAgo(_ date: Date?, reference: Date = Date()) -> String {
guard let date else { return "never" }
return date.timeAgoShort(reference: reference)
}
// MARK: - Navigation routes
/// Route into the article reader. Carries the parent story context so the
/// back button can show the truncated headline.
struct ArticleRoute: Hashable {
let articleId: String
let storyId: String
let parentHeadline: String
}

View File

@@ -0,0 +1,244 @@
// 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
@StateObject private var vm = StoryDetailViewModel()
@Query private var cachedArticles: [CachedArticle]
// 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<String>(); 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 cachedIds: Set<String> {
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 }
}
.toolbarBackground(Color.black, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.preferredColorScheme(.dark)
.task { await vm.load(id: story.id) }
}
// 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())
}
}