rename: Jarvis -> Jervis target, scheme, and folder throughout
Some checks failed
CI / Build · Debug (push) Has been cancelled
CI / Build · Release (push) Has been cancelled

The bundle-ID and display-name renames weren't enough — the actual
Xcode target/product name was still "Jarvis", which drives CFBundleName,
Xcode's Organizer archive list, the .xcodeproj filename, and the scheme
name. Renamed all the way through:

- project.yml: top-level name, target key, PRODUCT_NAME, source/info paths
- Jarvis/ -> Jervis/ (source folder, git-tracked as renames, no content
  diffs on the moved files)
- .gitignore, CI workflows, README, CONTRIBUTING: Jarvis.xcodeproj /
  scheme Jarvis -> Jervis.xcodeproj / scheme Jervis

Verified: xcodebuild -scheme Jervis succeeds, produces Jervis.app,
CFBundleName/CFBundleExecutable/CFBundleDisplayName all read "Jervis".
CFBundleIdentifier intentionally stays com.kisani.jarvis (per prior
decision — bundle ID isn't user-facing anywhere including Organizer).

Swift type names (JarvisApp, JarvisWordmark, etc.) and the Gitea repo
name/URL are unchanged — pure internal source identifiers, not user or
developer-facing product identity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Kutesir
2026-07-13 03:49:31 +03:00
parent 3a6f1bbdd8
commit bd632a1592
46 changed files with 20 additions and 19 deletions

View File

@@ -0,0 +1,321 @@
// 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 {
Palette.background.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 26) {
header
serverSection
remoteAccessSection
automationSection
providerSection
actions
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
}
.presentationDragIndicator(.visible)
.task { await recheck() }
}
// MARK: - Header
private var header: some View {
HStack {
Text("Connectivity")
.font(.system(size: 22, weight: .heavy))
.foregroundStyle(Palette.primaryText)
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(Palette.primaryText)
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(Palette.primaryText)
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(Palette.primaryText)
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(Palette.tertiaryText)
.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(Palette.primaryText)
.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(Palette.tertiaryText)
}
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(Palette.secondaryText)
.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(Palette.mutedText))
.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 Palette.tertiaryText
}
}
private func dot(for r: Reachability) -> some View {
Circle().fill(color(for: r)).frame(width: 9, height: 9)
}
}

View File

@@ -0,0 +1,113 @@
// 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 {
Palette.background.ignoresSafeArea()
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Add feed")
.font(.system(size: 22, weight: .heavy))
.foregroundStyle(Palette.primaryText)
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)
}
.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(Palette.tertiaryText)
TextField("", text: text,
prompt: Text(placeholder).foregroundColor(Palette.mutedText))
.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,328 @@
// 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 {
Palette.background.ignoresSafeArea()
VStack(spacing: 0) {
header
serverCard
searchBar
feedList
}
}
.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(Palette.tertiaryText)
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(Palette.tertiaryText)
}
.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(Palette.tertiaryText)
TextField("", text: $query, prompt: Text("Search feeds").foregroundColor(Palette.tertiaryText))
.font(.system(size: 15, weight: .regular))
.foregroundStyle(Palette.primaryText)
.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(Palette.background)
.refreshable { await vm.load() }
}
private func section(title: String, feeds: [Feed]) -> some View {
Section {
ForEach(feeds) { feed in
FeedRowView(feed: feed)
.listRowBackground(Palette.background)
.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(Palette.tertiaryText)
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 6, trailing: 16))
}
}
private var loadingRow: some View {
ProgressView().tint(Palette.orange)
.frame(maxWidth: .infinity)
.listRowBackground(Palette.background)
.listRowSeparator(.hidden)
}
private var emptyRow: some View {
Text(query.isEmpty ? (vm.error ?? "No feeds configured.") : "No feeds match “\(query)”.")
.font(.system(size: 13))
.foregroundStyle(Palette.tertiaryText)
.frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(Palette.background)
.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(Palette.primaryText)
.lineLimit(1)
Text(metaLine)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(Palette.tertiaryText)
.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,793 @@
// SignalFeedView.swift
// Jarvis home screen. Stories ranked by signal score, live connection state,
// topic filters, pull-to-refresh, infinite scroll.
import SwiftUI
import SwiftData
struct SignalFeedView: View {
@EnvironmentObject var store: StoryStore
@EnvironmentObject var ws: WebSocketManager
@EnvironmentObject var settings: ServerSettings
@Environment(\.modelContext) private var modelContext
@Query private var cachedStories: [CachedStory]
@Query private var cachedArticles: [CachedArticle]
@Query private var readStories: [ReadStory]
@Query private var savedStories: [SavedStory]
@State private var showFeeds = false
@State private var showSettings = false
@State private var selectedPill: StoryPill = .all
@State private var shareStory: StorySummary?
/// Snapshot of already-seen story ids, captured per load so the order stays
/// stable while scrolling (and refreshes to sink newly-seen ones).
@State private var seenSnapshot: Set<String> = []
/// Reference-type set so marking a story seen on scroll doesn't re-render.
@State private var seenTracker = SeenTracker()
@State private var showRead = false
@State private var markAllToast: String?
@State private var markAllPending = false
/// Stories that have full article content cached eligible for the green dot.
private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) }
private var readStoryIds: Set<String> { Set(readStories.map(\.id)) }
private var savedStoryIds: Set<String> { Set(savedStories.map(\.id)) }
/// Live stories when connected; cached fallback while loading or offline so
/// switching to Tech / AI / F1 shows cached cards instantly.
/// Before the first sync of this session completes, only unread stories are
/// surfaced read content is irrelevant until we know there's nothing newer.
private var filteredStories: [StorySummary] {
let base: [StorySummary]
if !store.stories.isEmpty {
base = store.stories.sorted(by: StorySummary.feedOrder)
} else {
base = cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
}
let matched = base.filter { $0.matches(selectedPill) }
if store.lastSyncedAt == nil {
return matched.filter { !readStoryIds.contains($0.id) }
}
return matched
}
/// The main feed: unread stories, with already-seen ones sunk below fresh
/// ones, and fossils (old by firstSeenAt, see StorySummary.isFossil) sunk
/// below everything a high signalScore alone can't make old news lead.
private var mainStories: [StorySummary] {
let notRead = filteredStories.filter { !readStoryIds.contains($0.id) }
let current = notRead.filter { !$0.isFossil }
let fossils = notRead.filter { $0.isFossil }
let fresh = current.filter { !seenSnapshot.contains($0.id) }
let seen = current.filter { seenSnapshot.contains($0.id) }
return fresh + seen + fossils
}
/// Read stories, tucked into the collapsible "Read" shelf.
private var readItems: [StorySummary] {
filteredStories.filter { readStoryIds.contains($0.id) }
}
/// Every pill shows the card layout sections are scoped to "All".
private var isDigest: Bool { true }
/// Front-page digest: Top Stories + up to 4 per category section, deduped.
/// Sections that score poorly in the global top-100 are supplemented from
/// per-section targeted fetches held in store.sectionSupplement.
private func makeDigest(sections: [NewsSection]) -> (top: [StorySummary], sections: [(NewsSection, [StorySummary])]) {
let unread = filteredStories.filter { !readStoryIds.contains($0.id) }
// Fossils (old by firstSeenAt) sink below current stories so the lead
// slot is never a high-scoring but stale story see StorySummary.isFossil.
let current = unread.filter { !$0.isFossil }
let fossils = unread.filter { $0.isFossil }
let reordered = current + fossils
let top = Array(reordered.prefix(5))
var pool = Array(reordered.dropFirst(5))
var usedIds = Set(top.map(\.id))
var out: [(NewsSection, [StorySummary])] = []
for section in sections {
var picked: [StorySummary] = [], rest: [StorySummary] = []
for s in pool {
if picked.count < 4 && s.inSection(section) { picked.append(s) }
else { rest.append(s) }
}
pool = rest
// Supplement from section-specific fetch when main pool is sparse
if picked.count < 2, let extra = store.sectionSupplement[section.id] {
for s in extra where !usedIds.contains(s.id) && s.inSection(section) {
picked.append(s)
usedIds.insert(s.id)
if picked.count == 4 { break }
}
}
usedIds.formUnion(picked.map(\.id))
if !picked.isEmpty { out.append((section, picked)) }
}
return (top, out)
}
private func captureSeenSnapshot() {
let ids = (try? modelContext.fetch(FetchDescriptor<SeenStory>()))?.map(\.id) ?? []
seenSnapshot = Set(ids)
seenTracker.ids = seenSnapshot
}
/// Hot path runs as each row scrolls in. O(1), no disk write, no re-render:
/// the container autosaves, and SeenStory isn't @Query'd by this view.
private func markSeen(_ s: StorySummary) {
guard seenTracker.ids.insert(s.id).inserted else { return }
modelContext.insert(SeenStory(id: s.id))
}
private func markUnread(_ s: StorySummary) {
let id = s.id
if let row = (try? modelContext.fetch(
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
try? modelContext.save()
Task { try? await APIClient.shared.markStoryUnread(id: id) }
}
}
var body: some View {
NavigationStack {
ZStack(alignment: .bottom) {
Palette.background.ignoresSafeArea()
VStack(spacing: 0) {
header
syncBar
topicPills
Divider().overlay(Palette.hairline)
feedList
}
if let toast = markAllToast {
Text(toast)
.font(.system(size: 13, weight: .medium, design: .monospaced))
.foregroundStyle(Color(hex: "E0E0E0"))
.padding(.horizontal, 18)
.padding(.vertical, 11)
.background(Color(hex: "1E1E1E"))
.clipShape(Capsule())
.overlay(Capsule().stroke(Color(hex: "333333"), lineWidth: 0.5))
.shadow(color: .black.opacity(0.4), radius: 12, y: 4)
.padding(.bottom, 24)
.transition(.move(edge: .bottom).combined(with: .opacity))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
withAnimation(.easeOut(duration: 0.25)) { markAllToast = nil }
}
}
}
}
.navigationBarHidden(true)
.navigationDestination(for: StorySummary.self) { story in
StoryDetailView(story: story)
}
.navigationDestination(for: ArticleRoute.self) { route in
ArticleReaderView(route: route)
}
.sheet(isPresented: $showFeeds) {
FeedManagerView()
}
.sheet(isPresented: $showSettings) {
SettingsView()
}
.sheet(item: $shareStory) { story in
ShareSheet(items: shareItems(story))
}
}
.onChange(of: store.stories) { _, newValue in
cacheStories(newValue)
}
.task { CacheMaintenance.prune(modelContext) } // bound the caches on launch
.task(id: selectedPill) {
await store.setPill(selectedPill) // paginates to 100 stories internally
}
}
// MARK: - Header
private var header: some View {
HStack(alignment: .center, spacing: 0) {
JarvisWordmark(size: 26)
Spacer()
// Connection + controls as one coherent surface
HStack(spacing: 2) {
ConnectionBanner(state: ws.connectionState)
Button {
showFeeds = true
} label: {
Image(systemName: "dot.radiowaves.up.forward")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Palette.secondaryText)
.frame(width: 40, height: 40)
}
Menu {
Button {
Task { await markAllRead() }
} label: {
Label(
markAllPending ? "Marking…" : "Mark all as read",
systemImage: markAllPending ? "hourglass" : "checkmark.circle"
)
}
.disabled(markAllPending || mainStories.isEmpty)
Divider()
Button {
showSettings = true
} label: {
Label("Settings", systemImage: "gearshape")
}
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Palette.secondaryText)
.frame(width: 40, height: 40)
}
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
}
// MARK: - Sync bar
/// True while the first sync of this session hasn't completed yet, OR while
/// a fetch is actively in flight. Used to gate the "caught up" state so we
/// never declare victory before we've actually checked the server.
private var isSyncing: Bool { store.isLoading || store.lastSyncedAt == nil }
private var syncBar: some View {
HStack(spacing: 5) {
if isSyncing {
ProgressView()
.progressViewStyle(.circular)
.controlSize(.mini)
.tint(Palette.orange)
}
Text(isSyncing ? "Syncing signals…" : syncText)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(Color(hex: "5A5A5A"))
.animation(.easeInOut(duration: 0.15), value: store.isLoading)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
private var syncText: String {
guard let last = store.lastSyncedAt else { return "Loading from cache…" }
let ago = last.timeAgoShort()
let phrase = ago == "just now" ? ago : "\(ago) ago"
if ws.connectionState.isLive {
return "Synced \(phrase) · \(cachedStoryIds.count) cached"
} else {
return "Last synced \(phrase)"
}
}
// MARK: - Pills
private var topicPills: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(StoryPill.allCases) { p in
pill(p.label, selected: selectedPill == p) {
selectedPill = p
}
}
}
.padding(.horizontal, 16)
}
.padding(.bottom, 14)
}
private func pill(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 12, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? .black : Palette.secondaryText)
.padding(.horizontal, 13)
.frame(height: 28)
.background(selected ? Palette.orange : Palette.surface)
.clipShape(Capsule())
}
}
// MARK: - Column headers
private var columnHeaders: some View {
HStack {
Text("STORY")
.kerning(0.8)
Spacer()
Text("SIGNAL ↓")
.kerning(0.8)
}
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundStyle(Palette.tertiaryText)
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
// MARK: - Feed list
private var feedList: some View {
List {
digestContent
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Palette.background)
.animation(.snappy, value: showRead)
.animation(.snappy, value: readStories.count)
.onAppear {
captureSeenSnapshot()
// If the cache already shows all-read on first render, open the
// shelf immediately so the user sees their previous stories.
if !readItems.isEmpty && mainStories.isEmpty { showRead = true }
}
// Expand shelf when loading finishes and there are still no unread stories.
.onChange(of: store.isLoading) { _, loading in
if !loading, !readItems.isEmpty, mainStories.isEmpty {
withAnimation(.snappy) { showRead = true }
}
}
// Expand shelf if SwiftData @Query hydrates after the first render and
// all cached stories turn out to already be read.
.onChange(of: readItems.count) { _, count in
if count > 0, mainStories.isEmpty, !showRead {
showRead = true
}
}
.refreshable {
seenSnapshot = []
seenTracker.ids = []
await store.loadFeedFull()
// Merge any stories cached by background refresh that aren't in
// the live feed yet mergeStories deduplicates, so always safe.
let liveIds = Set(store.stories.map(\.id))
let extra = cachedStories
.filter { !liveIds.contains($0.id) }
.map(StorySummary.init(cached:))
if !extra.isEmpty {
await store.mergeStories(extra)
}
}
}
/// Card layout for every pill.
/// "All" multi-section digest. Tech (sub-sections) sub-section digest.
/// Everything else hero card + flat scroll of all stories for that pill.
@ViewBuilder private var digestContent: some View {
if selectedPill == .all {
let d = makeDigest(sections: NewsSection.sections)
sectionHeader("TOP STORIES", pill: nil)
pillContent(top: d.top)
ForEach(d.sections, id: \.0.id) { section, stories in
sectionHeader(section.label.uppercased(), pill: section.pill)
ForEach(stories) { mainRow($0) }
}
} else if let subSections = selectedPill.subSections {
let d = makeDigest(sections: subSections)
sectionHeader(selectedPill.label.uppercased(), pill: nil)
pillContent(top: d.top)
ForEach(d.sections, id: \.0.id) { section, stories in
sectionHeader(section.label.uppercased(), pill: section.pill)
ForEach(stories) { mainRow($0) }
}
} else {
// Flat list: hero + all stories for this pill
sectionHeader(selectedPill.label.uppercased(), pill: nil)
pillContent(top: mainStories, flat: true)
}
readShelf
}
/// Shared hero + body rows. `flat` = render all items; otherwise only top 5.
@ViewBuilder private func pillContent(top stories: [StorySummary], flat: Bool = false) -> some View {
if let lead = stories.first {
mainRow(lead, hero: true)
let rest = flat ? Array(stories.dropFirst()) : Array(stories.dropFirst().prefix(4))
ForEach(rest) { mainRow($0) }
} else if !filteredStories.isEmpty {
if isSyncing { checkingRow.plainBlackRow() }
else { caughtUpRow.plainBlackRow() }
} else {
emptyState.plainBlackRow()
}
}
private func sectionHeader(_ label: String, pill: StoryPill?) -> some View {
HStack(alignment: .firstTextBaseline) {
Text(label)
.font(.system(size: 13, weight: .heavy, design: .monospaced))
.kerning(0.6)
.foregroundStyle(Palette.primaryText)
Rectangle().fill(Palette.orange).frame(width: 18, height: 2)
Spacer()
if let pill {
Button {
selectedPill = pill
} label: {
HStack(spacing: 2) {
Text("See all"); Image(systemName: "chevron.right").font(.system(size: 10, weight: .bold))
}
.font(.system(size: 12, weight: .bold))
.foregroundStyle(Palette.orange)
}
}
}
.padding(.horizontal, 16).padding(.top, 22).padding(.bottom, 10)
.plainBlackRow()
}
@ViewBuilder
private func mainRow(_ story: StorySummary, hero: Bool = false) -> some View {
NavigationLink(value: story) {
if hero {
heroCard(story)
} else {
StoryRowView(story: story, isCached: cachedStoryIds.contains(story.id))
}
}
.listRowInsets(hero ? EdgeInsets(top: 2, leading: 16, bottom: 12, trailing: 16) : EdgeInsets())
.listRowBackground(Palette.background)
.listRowSeparator(.hidden)
.onAppear {
markSeen(story)
if story.id == mainStories.last?.id { Task { await store.loadMore() } }
}
// Swipe left: short swipe reveals Save (tap); a full/long swipe marks read.
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { markRead(story) } label: {
Label("Mark read", systemImage: "checkmark.circle")
}
.tint(Color(hex: "3A5A7A"))
Button { toggleSave(story) } label: {
Label(isSaved(story) ? "Saved" : "Save",
systemImage: isSaved(story) ? "bookmark.fill" : "bookmark")
}
.tint(Palette.healthActive)
}
// Swipe right Share.
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button { markRead(story); shareStory = story } label: {
Label("Share", systemImage: "square.and.arrow.up")
}
.tint(Palette.orange)
}
}
/// Apple-News-style lead card for the #1 Top Story flat surface, big type.
private func heroCard(_ story: StorySummary) -> some View {
HStack(alignment: .top, spacing: 0) {
SignalStripe(score: story.signalScore, width: 4)
VStack(alignment: .leading, spacing: 11) {
HStack(alignment: .center) {
Text(Topic.label(story.topic).uppercased())
.font(.system(size: 10, weight: .bold, design: .monospaced)).kerning(0.8)
.foregroundStyle(Palette.secondaryText)
Spacer()
Text("\(story.signalScore)")
.font(.system(size: 15, weight: .semibold, design: .monospaced))
.foregroundStyle(Signal.scoreColor(story.signalScore))
}
Text(story.headline)
.font(.system(size: 25, weight: .bold, design: .default))
.foregroundStyle(Palette.primaryText)
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
if !story.summary.isEmpty {
Text(story.summary)
.font(.system(size: 16, weight: .regular, design: .default))
.foregroundStyle(Palette.bodyText)
.lineLimit(3).lineSpacing(2)
.fixedSize(horizontal: false, vertical: true)
}
if !story.sources.isEmpty {
SourceChips(names: story.sources.map(\.name), total: story.sourceCount)
}
Text("\(story.sourceCount) sources · \((story.firstSeenAt ?? story.updatedAt).timeAgoShort()) ago")
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(Palette.tertiaryText)
}
.padding(16)
Spacer(minLength: 0)
}
.background(Palette.surface)
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Palette.surface2, lineWidth: 0.5))
}
/// Shown when all cached stories are read AND the first sync hasn't finished yet.
/// Keeps the UI honest we haven't confirmed "no new content" until the server responds.
private var checkingRow: some View {
VStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
.controlSize(.mini)
.tint(Palette.orange)
Text("Checking for new signals…")
.font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundStyle(Palette.tertiaryText)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 50)
}
private var caughtUpRow: some View {
VStack(spacing: 10) {
Image(systemName: "checkmark.circle")
.font(.system(size: 32)).foregroundStyle(Color(hex: "3E7E3E"))
Text("You're all caught up").font(.system(size: 16, weight: .semibold, design: .default)).foregroundStyle(Palette.primaryText)
Text("Pull to refresh for more")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
}
.frame(maxWidth: .infinity).padding(.vertical, 70)
}
// Collapsible "Read" shelf at the bottom read stories as slim rows.
@ViewBuilder
private var readShelf: some View {
if !readItems.isEmpty {
Button {
withAnimation(.snappy) { showRead.toggle() }
} label: {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 13)).foregroundStyle(Color(hex: "3E7E3E"))
Text("READ").font(.system(size: 11, weight: .bold, design: .monospaced)).kerning(0.8)
Text("\(readItems.count)")
.font(.system(size: 11, weight: .bold, design: .monospaced))
.foregroundStyle(Color(hex: "888888"))
Spacer()
Image(systemName: showRead ? "chevron.up" : "chevron.down")
.font(.system(size: 11, weight: .bold))
}
.foregroundStyle(Palette.tertiaryText)
.padding(.horizontal, 16).padding(.vertical, 16)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.plainBlackRow()
if showRead {
ForEach(readItems) { story in
NavigationLink(value: story) {
StoryRowView(story: story, isCached: cachedStoryIds.contains(story.id),
isRead: true, compact: true)
}
.plainBlackRow()
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { markUnread(story) } label: {
Label("Unread", systemImage: "circle")
}
.tint(Color(hex: "3A5A7A"))
Button { toggleSave(story) } label: {
Label(isSaved(story) ? "Saved" : "Save",
systemImage: isSaved(story) ? "bookmark.fill" : "bookmark")
}
.tint(Palette.healthActive)
}
}
}
}
}
private var emptyState: some View {
VStack(spacing: 12) {
if isSyncing {
// First sync hasn't completed yet, or a fetch is actively in flight.
ProgressView().tint(Palette.orange)
Text("Polling signals…")
.font(.system(size: 14, weight: .heavy))
.foregroundStyle(Color(hex: "AAAAAA"))
hostLine
} else if let error = store.error {
// Reached the empty state because the fetch failed.
emptyIcon("exclamationmark.triangle", color: Color(hex: "C25555"))
Text("Couldn't reach the server")
.font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
Text(error.errorDescription ?? "Unknown error")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
.multilineTextAlignment(.center)
hostLine
refreshButton("Retry")
} else if !ws.connectionState.isLive {
emptyIcon("wifi.slash")
Text("Offline").font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
Text("No cached stories to show")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
refreshButton("Retry")
} else if selectedPill != .all {
emptyIcon("line.3.horizontal.decrease.circle")
Text("No \(selectedPill.label) stories yet")
.font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
if store.isLoadingMore || store.hasMore {
Text("Pulling more to find them…")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
ProgressView().tint(Palette.orange)
} else {
Text("Nothing in the feed matched")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
refreshButton("Refresh")
}
} else {
// Connected, loaded, genuinely nothing yet.
emptyIcon("antenna.radiowaves.left.and.right")
Text("No signals yet").font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
Text("Pull to refresh")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
refreshButton("Refresh")
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 32)
.padding(.top, 80)
}
private func emptyIcon(_ name: String, color: Color = Palette.mutedText) -> some View {
Image(systemName: name).font(.system(size: 30)).foregroundStyle(color)
}
@ViewBuilder private var hostLine: some View {
if let host = settings.host {
Text(host)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Palette.tertiaryText)
}
}
private func refreshButton(_ title: String) -> some View {
Button {
Task { await store.loadFeedFull() }
} label: {
Text(title)
.font(.system(size: 14, weight: .bold)).foregroundStyle(.black)
.padding(.horizontal, 22).frame(height: 44)
.background(Palette.orange).clipShape(Capsule())
}
.padding(.top, 6)
}
// MARK: - Swipe actions
private func isSaved(_ s: StorySummary) -> Bool { savedStoryIds.contains(s.id) }
/// Swipe left toggle Save (bookmarks the story to the Saved tab).
private func toggleSave(_ s: StorySummary) {
let id = s.id
if let row = (try? modelContext.fetch(
FetchDescriptor<SavedStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
} else {
cacheStories([s]) // keep metadata so it renders in Saved
modelContext.insert(SavedStory(id: id))
}
try? modelContext.save()
}
private func markRead(_ s: StorySummary) {
let id = s.id
let existing = try? modelContext.fetch(
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id }))
if existing?.first == nil {
modelContext.insert(ReadStory(id: id))
try? modelContext.save()
Task { try? await APIClient.shared.markStoryRead(id: id) }
withAnimation(.snappy) { showRead = true }
}
}
private func markAllRead() async {
let ids = mainStories.map(\.id).filter { !readStoryIds.contains($0) }
guard !ids.isEmpty else {
withAnimation { markAllToast = "No unread \(selectedPill.label) stories right now." }
return
}
markAllPending = true
// Optimistic: insert ReadStory for every ID being marked.
for id in ids {
let exists = (try? modelContext.fetch(
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first
if exists == nil { modelContext.insert(ReadStory(id: id)) }
}
try? modelContext.save()
withAnimation(.snappy) {
showRead = true
markAllToast = "Marked \(ids.count) \(ids.count == 1 ? "story" : "stories") as read."
}
do {
try await APIClient.shared.markAllRead(storyIds: ids, pill: selectedPill.label)
} catch {
// Rollback optimistic state.
for id in ids {
if let row = (try? modelContext.fetch(
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
}
}
try? modelContext.save()
withAnimation {
markAllToast = "Sync failed. Stories may reappear until retry."
}
}
markAllPending = false
}
private func shareItems(_ s: StorySummary) -> [Any] {
var items: [Any] = ["\(s.headline)\n\n\(s.summary)\n\nvia Jarvis"]
if let first = s.sources.first, let url = URL(string: first.url) {
items.append(url)
}
return items
}
// MARK: - Caching
private func cacheStories(_ stories: [StorySummary]) {
guard !stories.isEmpty else { return }
let container = modelContext.container
Task.detached {
let ctx = ModelContext(container)
let existing = (try? ctx.fetch(FetchDescriptor<CachedStory>())) ?? []
var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
for summary in stories {
if let found = byId[summary.id] {
found.signalScore = summary.signalScore
found.sourceCount = summary.sourceCount
found.tags = summary.tags ?? []
found.updatedAt = summary.updatedAt
} else {
let c = CachedStory(from: summary)
ctx.insert(c)
byId[summary.id] = c
}
}
try? ctx.save()
}
}
}
/// Session-seen ids in a reference type mutating it doesn't trigger SwiftUI
/// updates, so marking rows seen on scroll stays cheap.
private final class SeenTracker {
var ids = Set<String>()
}
// Map a cached story back into a StorySummary for offline rendering.
extension StorySummary {
init(cached: CachedStory) {
self.init(
id: cached.id,
headline: cached.headline,
summary: cached.summary,
topic: cached.topic,
tags: cached.tags,
signalScore: cached.signalScore,
scoreBreakdown: ScoreBreakdown(sourceAuthority: 0, freshness: 0, localRelevance: 0,
crossSourceConfirmation: 0, topicImportance: 0),
sourceCount: cached.sourceCount,
sources: [],
consensus: cached.consensus,
conflict: cached.conflict,
updatedAt: cached.updatedAt,
createdAt: cached.createdAt,
firstSeenAt: cached.firstSeenAt
)
}
}
private extension View {
/// Full-bleed black List row with no separator keeps the flat feed look.
func plainBlackRow() -> some View {
self.listRowInsets(EdgeInsets())
.listRowBackground(Palette.background)
.listRowSeparator(.hidden)
}
}

View File

@@ -0,0 +1,131 @@
// 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
/// True once the story has been opened drops the orange stripe and dims it.
var isRead: Bool = false
/// Slim single-line presentation, used for the "Read" shelf.
var compact: Bool = false
private var sourceNames: [String] { story.sources.map(\.name) }
var body: some View {
if compact { compactBody } else { fullBody }
}
private var compactBody: some View {
HStack(spacing: 0) {
SignalStripe(score: story.signalScore, muted: true)
Text(story.headline)
.font(.system(size: 15, weight: .semibold, design: .default))
.foregroundStyle(Palette.secondaryText)
.lineLimit(1)
.padding(.leading, 14)
.padding(.vertical, 11)
Spacer(minLength: 12)
Text("\(story.signalScore)")
.font(.system(size: 14, weight: .heavy, design: .monospaced))
.foregroundStyle(Color(hex: "474747"))
.padding(.trailing, 16)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.background)
.overlay(alignment: .bottom) { Rectangle().fill(Palette.hairline).frame(height: 0.5) }
.contentShape(Rectangle())
}
private var fullBody: some View {
HStack(alignment: .top, spacing: 0) {
if isRead {
// No stripe for read stories remove the orange signal indicator entirely.
Color.clear.frame(width: 3)
} else {
SignalStripe(score: story.signalScore)
}
VStack(alignment: .leading, spacing: 9) {
Text(story.headline)
.font(.system(size: 19, weight: .bold, design: .default))
.foregroundStyle(Signal.titleColor(story.signalScore))
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
if !story.summary.isEmpty {
Text(story.summary)
.font(.system(size: 15, weight: .regular, design: .default))
.foregroundStyle(Palette.bodyText)
.lineLimit(5)
.lineSpacing(3)
.fixedSize(horizontal: false, vertical: true)
}
if !sourceNames.isEmpty {
SourceChips(names: sourceNames, total: story.sourceCount)
}
Text(metaLine)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(Palette.tertiaryText)
}
.padding(.leading, 14)
.padding(.vertical, 16)
Spacer(minLength: 12)
// Signal cluster: score + offline dot, compact and aligned
VStack(alignment: .trailing, spacing: 4) {
Text("\(story.signalScore)")
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundStyle(Signal.scoreColor(story.signalScore))
if isCached {
CachedDot(size: 6)
}
}
.padding(.trailing, 16)
.padding(.top, 18)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.background)
.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")"
// firstSeenAt is when the cluster appeared updatedAt bumps on every
// article the backend attaches, so it can read "just now" on stories
// that are actually weeks old.
let age = (story.firstSeenAt ?? story.updatedAt).timeAgoShort()
return "\(sources) · \(Topic.label(story.topic)) · \(age) 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", tags: ["finance"], signalScore: score, scoreBreakdown: breakdown,
sourceCount: 7, sources: sources, consensus: nil, conflict: nil,
updatedAt: Date().addingTimeInterval(-3600), createdAt: Date(),
firstSeenAt: Date().addingTimeInterval(-3600)),
isCached: score == 64
)
}
}
.background(Palette.background)
}

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 {
Palette.background.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,493 @@
// ArticleReaderView.swift
// Jarvis full article reader. Caches to SwiftData on load; reads from cache offline.
import SwiftUI
import SwiftData
// MARK: - Scroll progress tracking
private struct ContentFrameKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) { value = nextValue() }
}
// MARK: - ViewModel
@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 {
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) }
}
}
// MARK: - View
struct ArticleReaderView: View {
let route: ArticleRoute
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var scheme
@StateObject private var vm = ArticleReaderViewModel()
@Query private var cachedArticles: [CachedArticle]
@Query private var readStories: [ReadStory]
@Query private var savedStories: [SavedStory]
@AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .dark
@State private var showShare = false
@State private var readProgress: Double = 0
// Adaptive palette
private var bg: Color { scheme == .dark ? Color(hex: "0A0A0A") : Color(hex: "F8F7F4") }
private var bodyFg: Color { scheme == .dark ? Color(hex: "CECECE") : Color(hex: "1A1916") }
private var headFg: Color { scheme == .dark ? Color(hex: "F2F2F2") : Color(hex: "0F0E0C") }
private var mutedFg: Color { scheme == .dark ? Color(hex: "616161") : Color(hex: "888882") }
private var hairline: Color { scheme == .dark ? Color(hex: "1F1F1F") : Color(hex: "E2E0D8") }
private var skelFg: Color { scheme == .dark ? Color(hex: "181818") : Color(hex: "E8E7E3") }
private var isCached: Bool { cachedArticles.contains { $0.id == route.articleId } }
private var isRead: Bool { readStories.contains { $0.id == route.storyId } }
private var isSaved: Bool { savedStories.contains { $0.id == route.storyId } }
private var backTitle: String {
let h = route.parentHeadline
return h.count > 28 ? String(h.prefix(28)).trimmingCharacters(in: .whitespaces) + "" : h
}
var body: some View {
ZStack {
bg.ignoresSafeArea()
VStack(spacing: 0) {
progressBar
scrollContent
}
}
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .topBarLeading) { backButton }
ToolbarItemGroup(placement: .topBarTrailing) { trailingButtons }
}
.toolbarBackground(bg, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
.task { await vm.load(route: route, context: modelContext) }
}
// MARK: - Progress bar
private var progressBar: some View {
ZStack(alignment: .leading) {
Rectangle().fill(hairline).frame(height: 2)
Rectangle()
.fill(Palette.orange)
.frame(width: UIScreen.main.bounds.width * readProgress, height: 2)
.animation(.linear(duration: 0.08), value: readProgress)
}
}
// MARK: - Scroll content
private var scrollContent: some View {
ScrollView {
VStack(spacing: 0) {
if let article = vm.article {
articleBody(article)
} else if vm.isLoading {
skeletonBody
} else {
errorBody
}
}
.padding(.bottom, 56)
.background(
GeometryReader { geo in
Color.clear.preference(
key: ContentFrameKey.self,
value: geo.frame(in: .named("reader"))
)
}
)
}
.coordinateSpace(name: "reader")
.onPreferenceChange(ContentFrameKey.self) { frame in
let viewH = UIScreen.main.bounds.height
let scrollable = frame.height - viewH
guard scrollable > 0 else { readProgress = 0; return }
readProgress = Double(min(1, max(0, -frame.minY / scrollable)))
}
}
// MARK: - Article layout
@ViewBuilder
private func articleBody(_ article: Article) -> some View {
VStack(alignment: .leading, spacing: 0) {
// Source + meta
HStack(spacing: 10) {
Text(article.source)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Palette.primaryText)
.lineLimit(1)
.padding(.horizontal, 10).padding(.vertical, 5)
.background(Palette.orange)
.clipShape(Capsule())
Text(article.publishedAt.clockShort)
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(mutedFg)
if isCached { cachedBadge }
Spacer()
}
.padding(.horizontal, 22)
.padding(.top, 24)
.padding(.bottom, 20)
// Headline
Text(article.headline)
.font(.system(size: 30, weight: .heavy))
.foregroundStyle(headFg)
.lineSpacing(2)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 22)
// Hero image
heroImage(article.imageUrl)
.padding(.horizontal, 16)
.padding(.top, 22)
// Author
if let author = article.author, !author.isEmpty {
Text(author.hasPrefix("By ") ? author : "By \(author)")
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(mutedFg)
.padding(.horizontal, 22)
.padding(.top, 18)
}
// Section break
Rectangle()
.fill(hairline)
.frame(height: 1)
.padding(.horizontal, 22)
.padding(.top, 26)
.padding(.bottom, 26)
// Body 17pt, generous line-height, readable contrast
Text(article.body)
.font(.system(size: 17, weight: .regular))
.foregroundStyle(bodyFg)
.lineSpacing(8.5)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: 680, alignment: .leading)
.padding(.horizontal, 22)
// Cluster
if !vm.siblings.isEmpty {
Rectangle()
.fill(hairline)
.frame(height: 1)
.padding(.horizontal, 22)
.padding(.top, 44)
.padding(.bottom, 24)
clusterSection
.padding(.horizontal, 22)
}
}
}
// MARK: - Hero image
@ViewBuilder
private func heroImage(_ urlString: String?) -> some View {
if let urlString, let url = URL(string: urlString) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable()
.aspectRatio(contentMode: .fill)
.transition(.opacity.animation(.easeIn(duration: 0.3)))
case .empty:
PulsingSkeleton(color: skelFg)
case .failure:
RoundedRectangle(cornerRadius: 12)
.fill(skelFg)
.overlay(
Image(systemName: "photo")
.font(.system(size: 24))
.foregroundStyle(mutedFg.opacity(0.5))
)
@unknown default: EmptyView()
}
}
.frame(height: 220)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Cached badge
private var cachedBadge: some View {
HStack(spacing: 4) {
Circle().fill(Color(hex: "4A9E4A")).frame(width: 5, height: 5)
Text("Cached")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(hex: "4A9E4A"))
}
}
// MARK: - Skeleton
private var skeletonBody: some View {
VStack(alignment: .leading, spacing: 0) {
PulsingSkeleton(color: skelFg)
.frame(width: 130, height: 26).clipShape(Capsule())
.padding(.horizontal, 22).padding(.top, 24)
VStack(alignment: .leading, spacing: 10) {
PulsingSkeleton(color: skelFg).frame(height: 30)
.clipShape(RoundedRectangle(cornerRadius: 6))
PulsingSkeleton(color: skelFg).frame(width: 260, height: 30)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.padding(.horizontal, 22).padding(.top, 16)
PulsingSkeleton(color: skelFg)
.frame(height: 220).clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16).padding(.top, 22)
VStack(alignment: .leading, spacing: 10) {
ForEach(0..<5, id: \.self) { i in
PulsingSkeleton(color: skelFg)
.frame(width: i == 4 ? 180 : .infinity, height: 14)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
.padding(.horizontal, 22).padding(.top, 32)
}
}
// MARK: - Error
private var errorBody: some View {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 30))
.foregroundStyle(mutedFg)
Text(vm.error ?? "Article unavailable.")
.font(.system(size: 14))
.foregroundStyle(mutedFg)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.top, 80)
.padding(.horizontal, 40)
}
// MARK: - Cluster section
private var clusterSection: some View {
VStack(alignment: .leading, spacing: 0) {
Text("MORE FROM THIS STORY")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.kerning(0.7)
.foregroundStyle(mutedFg)
.padding(.bottom, 16)
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: 6) {
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(mutedFg)
}
Text(entry.headline)
.font(.system(size: 15, weight: .regular))
.foregroundStyle(bodyFg)
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
}
.padding(.vertical, 14)
.overlay(alignment: .bottom) {
Rectangle().fill(hairline).frame(height: 0.5)
}
.contentShape(Rectangle())
}
// MARK: - Toolbar
private var backButton: some View {
Button { dismiss() } label: {
HStack(spacing: 4) {
Image(systemName: "chevron.left").font(.system(size: 14, weight: .semibold))
Text(backTitle).font(.system(size: 15, weight: .regular)).lineLimit(1)
}
.foregroundStyle(Palette.orange)
}
}
@ViewBuilder
private var trailingButtons: some View {
Button { showShare = true } label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(mutedFg)
}
Button { toggleSave() } label: {
Image(systemName: isSaved ? "bookmark.fill" : "bookmark")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(isSaved ? Palette.orange : mutedFg)
}
Button { toggleRead() } label: {
Image(systemName: isRead ? "checkmark.circle.fill" : "checkmark.circle")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(isRead ? Palette.orange : mutedFg)
}
Menu {
Picker("Appearance", selection: $appearanceMode) {
ForEach(AppearanceMode.allCases, id: \.self) { mode in
Label(mode.label, systemImage: mode.icon).tag(mode)
}
}
.pickerStyle(.inline)
} label: {
Image(systemName: appearanceMode.icon)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(mutedFg)
}
}
// MARK: - Actions
private func toggleRead() {
let id = route.storyId
if let row = (try? modelContext.fetch(
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
Task { try? await APIClient.shared.markStoryUnread(id: id) }
} else {
modelContext.insert(ReadStory(id: id))
Task { try? await APIClient.shared.markStoryRead(id: id) }
}
try? modelContext.save()
}
private func toggleSave() {
let id = route.storyId
if let row = (try? modelContext.fetch(
FetchDescriptor<SavedStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
} else {
modelContext.insert(SavedStory(id: id))
}
try? modelContext.save()
}
private var shareContent: [Any] {
guard let art = vm.article else { return [] }
var items: [Any] = ["\(art.headline)\n\nvia Jarvis"]
if !art.sourceUrl.isEmpty, let url = URL(string: art.sourceUrl) {
items.append(url)
}
return items
}
}
// MARK: - Pulsing skeleton primitive
private struct PulsingSkeleton: View {
let color: Color
@State private var on = false
var body: some View {
color.opacity(on ? 0.9 : 0.45)
.onAppear {
withAnimation(.easeInOut(duration: 0.85).repeatForever()) { on = true }
}
}
}
// MARK: - Offline Article reconstruction
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,33 @@
// 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"))
.task { await store.loadStories(refresh: true) }
}
}

View File

@@ -0,0 +1,192 @@
// 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(Palette.tertiaryText)
.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 readIds: 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),
isRead: readIds.contains(story.id))
}
.buttonStyle(.plain)
}
}
}
}
}
// MARK: - Latest (most recently formed stories first)
struct LatestView: View {
@EnvironmentObject var store: StoryStore
@Query private var cachedArticles: [CachedArticle]
@Query private var readStories: [ReadStory]
private var latest: [StorySummary] {
store.stories.sorted { $0.createdAt > $1.createdAt }
}
var body: some View {
NavigationStack {
ZStack {
Palette.background.ignoresSafeArea()
VStack(spacing: 0) {
TabHeader(title: "Latest")
Divider().overlay(Palette.hairline)
StoryList(stories: latest, cachedIds: Set(cachedArticles.map(\.storyId)),
readIds: Set(readStories.map(\.id)))
}
}
.navigationBarHidden(true)
.jarvisDestinations()
}
}
}
// MARK: - Saved (offline cache)
struct SavedView: View {
@Query private var cachedStories: [CachedStory]
@Query private var cachedArticles: [CachedArticle]
@Query private var readStories: [ReadStory]
@Query private var savedStories: [SavedStory]
private var stories: [StorySummary] {
let savedIds = Set(savedStories.map(\.id))
return cachedStories
.filter { savedIds.contains($0.id) }
.map(StorySummary.init(cached:))
.sorted(by: StorySummary.feedOrder)
}
var body: some View {
NavigationStack {
ZStack {
Palette.background.ignoresSafeArea()
VStack(spacing: 0) {
TabHeader(title: "Saved")
Divider().overlay(Palette.hairline)
if stories.isEmpty {
VStack(spacing: 12) {
Image(systemName: "bookmark")
.font(.system(size: 28)).foregroundStyle(Palette.mutedText)
Text("Nothing saved yet")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(Palette.tertiaryText)
Text("Swipe a story left to save it")
.font(.system(size: 12))
.foregroundStyle(Palette.mutedText)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
StoryList(stories: stories, cachedIds: Set(cachedArticles.map(\.storyId)),
readIds: Set(readStories.map(\.id)))
}
}
}
.navigationBarHidden(true)
.jarvisDestinations()
}
}
}
// MARK: - Search
struct SearchView: View {
@EnvironmentObject var store: StoryStore
@Query private var cachedArticles: [CachedArticle]
@Query private var readStories: [ReadStory]
@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(by: StorySummary.feedOrder)
}
var body: some View {
NavigationStack {
ZStack {
Palette.background.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)),
readIds: Set(readStories.map(\.id)))
}
}
}
.navigationBarHidden(true)
.jarvisDestinations()
}
}
private var searchBar: some View {
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.font(.system(size: 14)).foregroundStyle(Palette.tertiaryText)
TextField("", text: $query,
prompt: Text("Search").foregroundColor(Palette.tertiaryText))
.font(.system(size: 15))
.foregroundStyle(Palette.primaryText)
.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(Palette.tertiaryText)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

View File

@@ -0,0 +1,220 @@
// NotificationsView.swift
// Jarvis notification preferences: breaking alerts + morning/evening briefings
// with weather.
import SwiftUI
struct NotificationsView: View {
@EnvironmentObject var manager: NotificationManager
@StateObject private var settings = NotificationSettings.shared
@Environment(\.dismiss) private var dismiss
private var authorized: Bool {
manager.authStatus == .authorized || manager.authStatus == .provisional
}
var body: some View {
ZStack {
Palette.background.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 24) {
header
if !authorized { permissionCard }
alertsSection
briefingSection(title: "MORNING BRIEFING", icon: "sunrise",
on: $settings.morningEnabled,
hour: $settings.morningHour, minute: $settings.morningMinute)
briefingSection(title: "EVENING BRIEFING", icon: "sunset",
on: $settings.eveningEnabled,
hour: $settings.eveningHour, minute: $settings.eveningMinute)
weatherSection
testButton
}
.padding(.horizontal, 20).padding(.vertical, 12)
}
}
.navigationBarBackButtonHidden(false)
.task { await manager.refreshAuthStatus() }
.onChange(of: settings.morningEnabled) { _, _ in apply() }
.onChange(of: settings.eveningEnabled) { _, _ in apply() }
.onChange(of: settings.breakingEnabled) { _, _ in apply() }
.onChange(of: settings.weatherEnabled) { _, _ in apply() }
}
private func apply() { Task { await manager.applySettings() } }
// MARK: - Header
private var header: some View {
Text("Notifications").font(.system(size: 22, weight: .heavy)).foregroundStyle(Palette.primaryText)
.padding(.top, 4)
}
private var permissionCard: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Jervis needs permission to send briefings and breaking alerts.")
.font(.system(size: 14)).foregroundStyle(Color(hex: "BBBBBB"))
.fixedSize(horizontal: false, vertical: true)
Button {
Task { await manager.requestAuthorization() }
} label: {
Text("Enable notifications")
.font(.system(size: 15, weight: .bold)).foregroundStyle(.black)
.frame(maxWidth: .infinity).frame(height: 48)
.background(Palette.orange).clipShape(RoundedRectangle(cornerRadius: 12))
}
}
.padding(16).background(Palette.surface).clipShape(RoundedRectangle(cornerRadius: 14))
}
// MARK: - Alerts
private var alertsSection: some View {
section("ALERTS") {
toggleRow(icon: "bolt.fill", title: "Breaking & new stories",
subtitle: "Notify when a high-signal story breaks",
isOn: $settings.breakingEnabled)
if settings.breakingEnabled {
divider
HStack(spacing: 14) {
Image(systemName: "dial.medium").font(.system(size: 18))
.foregroundStyle(Palette.secondaryText).frame(width: 26)
Text("Only above signal").font(.system(size: 15, weight: .bold)).foregroundStyle(Palette.primaryText)
Spacer()
Stepper(value: $settings.breakingMinSignal, in: 0...100, step: 5) {}
.labelsHidden().fixedSize()
Text("\(settings.breakingMinSignal)")
.font(.system(size: 15, weight: .bold, design: .monospaced))
.foregroundStyle(Palette.orange).frame(width: 34, alignment: .trailing)
}
.padding(.horizontal, 14).padding(.vertical, 12)
}
}
}
// MARK: - Briefings
private func briefingSection(title: String, icon: String, on: Binding<Bool>,
hour: Binding<Int>, minute: Binding<Int>) -> some View {
section(title) {
toggleRow(icon: icon, title: "Daily briefing",
subtitle: "Weather + top stories to watch", isOn: on)
if on.wrappedValue {
divider
HStack(spacing: 14) {
Image(systemName: "clock").font(.system(size: 18))
.foregroundStyle(Palette.secondaryText).frame(width: 26)
Text("Time").font(.system(size: 15, weight: .bold)).foregroundStyle(Palette.primaryText)
Spacer()
DatePicker("", selection: timeBinding(hour: hour, minute: minute),
displayedComponents: .hourAndMinute)
.labelsHidden()
.onChange(of: hour.wrappedValue) { _, _ in apply() }
.onChange(of: minute.wrappedValue) { _, _ in apply() }
}
.padding(.horizontal, 14).padding(.vertical, 10)
}
}
}
private func timeBinding(hour: Binding<Int>, minute: Binding<Int>) -> Binding<Date> {
Binding(
get: {
Calendar.current.date(from: DateComponents(hour: hour.wrappedValue,
minute: minute.wrappedValue)) ?? Date()
},
set: { newDate in
let c = Calendar.current.dateComponents([.hour, .minute], from: newDate)
hour.wrappedValue = c.hour ?? hour.wrappedValue
minute.wrappedValue = c.minute ?? minute.wrappedValue
}
)
}
// MARK: - Weather
private var weatherSection: some View {
section("WEATHER") {
toggleRow(icon: "cloud.sun", title: "Include weather",
subtitle: "Today's outlook and rain in your briefing",
isOn: $settings.weatherEnabled)
if settings.weatherEnabled {
divider
HStack(spacing: 14) {
Image(systemName: "mappin.and.ellipse").font(.system(size: 18))
.foregroundStyle(Palette.secondaryText).frame(width: 26)
VStack(alignment: .leading, spacing: 3) {
Text("Location").font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
TextField("", text: $settings.locationName,
prompt: Text("Kampala").foregroundColor(Palette.mutedText))
.font(.system(size: 16, weight: .regular))
.foregroundStyle(Palette.primaryText)
.autocorrectionDisabled()
.onSubmit { resolveLocation() }
if let place = settings.resolvedPlace {
Text(place).font(.system(size: 11, design: .monospaced))
.foregroundStyle(Color(hex: "33C25E"))
}
}
Spacer()
Button { resolveLocation() } label: {
Text("Set").font(.system(size: 13, weight: .bold)).foregroundStyle(.black)
.padding(.horizontal, 12).frame(height: 32)
.background(Palette.orange).clipShape(Capsule())
}
}
.padding(.horizontal, 14).padding(.vertical, 12)
}
}
}
private func resolveLocation() {
Task {
settings.latitude = nil; settings.longitude = nil; settings.resolvedPlace = nil
await manager.applySettings() // triggers geocode + reschedule
}
}
// MARK: - Test
private var testButton: some View {
Button { Task { await manager.sendTestBriefing() } } label: {
Text("Send a test briefing")
.font(.system(size: 15, weight: .bold)).foregroundStyle(Palette.primaryText)
.frame(maxWidth: .infinity).frame(height: 50)
.background(Palette.surface2).clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(!authorized)
.opacity(authorized ? 1 : 0.5)
}
// MARK: - Building blocks
private func section<C: View>(_ label: String, @ViewBuilder _ content: () -> C) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(label).font(.system(size: 11, weight: .bold, design: .monospaced))
.kerning(0.8).foregroundStyle(Palette.tertiaryText)
VStack(spacing: 0) { content() }
.background(Palette.surface).clipShape(RoundedRectangle(cornerRadius: 14))
}
}
private func toggleRow(icon: String, title: String, subtitle: String, isOn: Binding<Bool>) -> some View {
HStack(spacing: 14) {
Image(systemName: icon).font(.system(size: 18)).foregroundStyle(Palette.secondaryText).frame(width: 26)
Toggle(isOn: isOn) {
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(Palette.primaryText)
Text(subtitle).font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
.fixedSize(horizontal: false, vertical: true)
}
}
.tint(Palette.orange)
}
.padding(.horizontal, 14).padding(.vertical, 14)
}
private var divider: some View {
Rectangle().fill(Palette.surface2).frame(height: 1).padding(.leading, 54)
}
}

View File

@@ -0,0 +1,147 @@
// SettingsView.swift
// Jarvis settings hub: Notifications, Connectivity, and server info.
import SwiftUI
import SwiftData
struct SettingsView: View {
@EnvironmentObject var settings: ServerSettings
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .dark
@Query private var cachedStories: [CachedStory]
@Query private var cachedArticles: [CachedArticle]
@State private var showClearConfirm = false
var body: some View {
NavigationStack {
ZStack {
Palette.background.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 26) {
header
group("APPEARANCE") {
appearancePicker
}
group("GENERAL") {
NavigationLink { NotificationsView() } label: {
row(icon: "bell.badge", title: "Notifications",
subtitle: "Briefings & breaking alerts")
}
divider
NavigationLink { ConnectivityView() } label: {
row(icon: "globe", title: "Connectivity",
subtitle: "Server address & Tailscale remote")
}
}
group("PLATFORM") {
row(icon: "server.rack", title: settings.host ?? "Not configured",
subtitle: "Connected server", chevron: false)
}
group("STORAGE") {
Button { showClearConfirm = true } label: {
row(icon: "trash", title: "Clear offline cache",
subtitle: "\(cachedStories.count) stories · \(cachedArticles.count) articles · keeps saved",
chevron: false, tint: Color(hex: "C25555"))
}
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
}
.navigationBarHidden(true)
}
.presentationDragIndicator(.visible)
.confirmationDialog("Clear offline cache?", isPresented: $showClearConfirm, titleVisibility: .visible) {
Button("Clear cache", role: .destructive) { CacheMaintenance.clear(modelContext) }
Button("Cancel", role: .cancel) {}
} message: {
Text("Removes cached stories, articles, and read/seen history. Your saved stories are kept.")
}
}
private var appearancePicker: some View {
HStack(spacing: 14) {
Image(systemName: "circle.lefthalf.filled")
.font(.system(size: 18))
.foregroundStyle(Palette.secondaryText)
.frame(width: 26)
Text("Theme")
.font(.system(size: 16, weight: .heavy))
.foregroundStyle(Palette.primaryText)
Spacer()
HStack(spacing: 0) {
ForEach(AppearanceMode.allCases, id: \.self) { mode in
Button {
appearanceMode = mode
} label: {
Text(mode.label)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(appearanceMode == mode ? .black : Palette.secondaryText)
.padding(.horizontal, 12)
.frame(height: 30)
.background(appearanceMode == mode ? Palette.orange : Color.clear)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.background(Palette.surface2)
.clipShape(Capsule())
.overlay(Capsule().stroke(Palette.surface2, lineWidth: 0.5))
}
.padding(.horizontal, 14).padding(.vertical, 14)
.contentShape(Rectangle())
}
private var header: some View {
HStack {
Text("Settings").font(.system(size: 22, weight: .heavy)).foregroundStyle(Palette.primaryText)
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark").font(.system(size: 15, weight: .bold))
.foregroundStyle(Palette.secondaryText).frame(width: 44, height: 44)
}
}
.padding(.top, 8)
}
private func group<C: View>(_ label: String, @ViewBuilder _ content: () -> C) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(label).font(.system(size: 11, weight: .bold, design: .monospaced))
.kerning(0.8).foregroundStyle(Palette.tertiaryText)
VStack(spacing: 0) { content() }
.background(Palette.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
private func row(icon: String, title: String, subtitle: String,
chevron: Bool = true, tint: Color? = nil) -> some View {
let resolvedTint = tint ?? Palette.primaryText
return HStack(spacing: 14) {
Image(systemName: icon).font(.system(size: 18))
.foregroundStyle(tint == nil ? Palette.secondaryText : resolvedTint).frame(width: 26)
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(resolvedTint).lineLimit(1)
Text(subtitle).font(.system(size: 12)).foregroundStyle(Palette.secondaryText).lineLimit(1)
}
Spacer()
if chevron {
Image(systemName: "chevron.right").font(.system(size: 13, weight: .bold))
.foregroundStyle(Palette.tertiaryText)
}
}
.padding(.horizontal, 14).padding(.vertical, 14).frame(minHeight: 44)
.contentShape(Rectangle())
}
private var divider: some View {
Rectangle().fill(Palette.surface2).frame(height: 1).padding(.leading, 54)
}
}

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(Palette.background)
}

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 Palette.tertiaryText
}
}
}
/// 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(Palette.background)
}

View File

@@ -0,0 +1,15 @@
// ShareSheet.swift
// Jarvis thin wrapper around UIActivityViewController for the share action.
import SwiftUI
import UIKit
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
}

View File

@@ -0,0 +1,33 @@
// 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
/// Read stories drop the colored stripe for a neutral one.
var muted: Bool = false
var body: some View {
Rectangle()
.fill(muted ? Palette.hairline : 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(Palette.background)
}

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: 11, weight: .semibold))
.foregroundStyle(muted ? Palette.chipMutedText : Palette.chipText)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Palette.chipFill)
.clipShape(Capsule())
}
}
#Preview {
SourceChips(names: ["Daily Monitor", "NilePost", "The EastAfrican"], total: 7)
.padding()
.background(Palette.background)
}

View File

@@ -0,0 +1,441 @@
// 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
import UIKit
// 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)
}
}
extension UIColor {
convenience init(hex: String) {
let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
var v: UInt64 = 0
Scanner(string: s).scanHexInt64(&v)
let r, g, b: CGFloat
switch s.count {
case 6:
r = CGFloat((v >> 16) & 0xFF) / 255
g = CGFloat((v >> 8) & 0xFF) / 255
b = CGFloat(v & 0xFF) / 255
default:
r = 0; g = 0; b = 0
}
self.init(red: r, green: g, blue: b, alpha: 1)
}
}
// MARK: - Palette
enum Palette {
static let orange = Color("KisaniOrange") // #FF5C00
static func adaptive(dark: String, light: String) -> Color {
Color(UIColor { traits in
traits.userInterfaceStyle == .dark ? UIColor(hex: dark) : UIColor(hex: light)
})
}
static let background = adaptive(dark: "0A0A0A", light: "F7F4EF")
static let black = background
static let surface = adaptive(dark: "111111", light: "EFE9DD")
static let surface2 = adaptive(dark: "1A1A1A", light: "E3DBCE")
static let hairline = adaptive(dark: "1A1A1A", light: "D6CEC0")
static let primaryText = adaptive(dark: "F5F2ED", light: "171411")
static let secondaryText = adaptive(dark: "9A9A9A", light: "4F4941")
static let tertiaryText = adaptive(dark: "666666", light: "6F675E")
static let mutedText = adaptive(dark: "4A4A4A", light: "948B80")
static let chipFill = adaptive(dark: "242424", light: "DDD3C1")
static let chipText = adaptive(dark: "E0E0E0", light: "28231E")
static let chipMutedText = adaptive(dark: "787878", light: "68625A")
// health
static let healthActive = adaptive(dark: "2A5A2A", light: "DCEBDD")
static let healthFailing = adaptive(dark: "AA6600", light: "F2D6A6")
static let healthDead = adaptive(dark: "5A1A1A", light: "E9C7C7")
// blocks
static let consensusBorder = Color(hex: "FF5C00")
static let consensusFill = adaptive(dark: "1F1206", light: "FFE2CA")
static let conflictBorder = adaptive(dark: "5A1A1A", light: "BF5B5B")
static let conflictFill = adaptive(dark: "1A0C0C", light: "F3D8D8")
static let cachedGreen = adaptive(dark: "2A5A2A", light: "2C6A38")
static let bodyText = adaptive(dark: "8A8A8A", light: "4A453F")
}
// 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)
}
private static func lerpTuple(_ a: (Double, Double, Double),
_ b: (Double, Double, Double),
_ t: Double) -> (Double, Double, Double) {
let t = max(0, min(1, t))
return (
a.0 + (b.0 - a.0) * t,
a.1 + (b.1 - a.1) * t,
a.2 + (b.2 - a.2) * t
)
}
private static func adaptiveLerp(_ darkA: (Double, Double, Double),
_ darkB: (Double, Double, Double),
_ lightA: (Double, Double, Double),
_ lightB: (Double, Double, Double),
_ t: Double) -> Color {
Color(UIColor { traits in
let rgb = traits.userInterfaceStyle == .dark
? lerpTuple(darkA, darkB, t)
: lerpTuple(lightA, lightB, t)
return UIColor(red: rgb.0, green: rgb.1, blue: rgb.2, alpha: 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 Palette.primaryText }
if score >= 20 {
let t = Double(score - 20) / Double(97 - 20)
return adaptiveLerp(
(0x44/255, 0x44/255, 0x44/255), (0xF5/255, 0xF2/255, 0xED/255),
(0x8A/255, 0x81/255, 0x76/255), (0x17/255, 0x14/255, 0x11/255),
t
)
}
let t = Double(score) / 20.0
return adaptiveLerp(
(0x1C/255, 0x1C/255, 0x1C/255), (0x44/255, 0x44/255, 0x44/255),
(0xD6/255, 0xCE/255, 0xC0/255), (0x8A/255, 0x81/255, 0x76/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 "ai", "artificial-intelligence": return "AI"
case "us", "united-states": return "US"
case "formula-1": return "Formula 1"
case "ui-ux", "web-design-and-ui-ux": return "Web Design"
default:
// Slug Title Case, e.g. "cloud-computing" "Cloud Computing".
return slug.split(separator: "-")
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
.joined(separator: " ")
}
}
/// 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
}
// MARK: - Appearance mode
enum AppearanceMode: String, CaseIterable {
case system, light, dark
var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
var label: String {
switch self {
case .system: return "System"
case .light: return "Light"
case .dark: return "Dark"
}
}
var icon: String {
switch self {
case .system: return "circle.lefthalf.filled"
case .light: return "sun.max"
case .dark: return "moon.stars"
}
}
}
// MARK: - Freshness-weighted feed ordering
//
// The server already applies freshness when computing signalScore, but that
// snapshot ages once it's cached on the client. clientRank applies a lightweight
// time-decay so fresh stories naturally surface over stale high-scorers.
//
// Formula: rank = score × (0.80 + 0.20 × decay)
// decay = 1 / (1 + ageHours / 24) 1.0 now · 0.50 @ 24 h · 0.33 @ 48 h
//
// Effect: a score-80 story from 1 hour ago (rank 79.8) beats a score-82 story
// from 48 hours ago (rank 71.4). Pure score still dominates for same-age stories.
//
// Age is measured from `firstSeenAt`, not `updatedAt` the backend bumps
// `updatedAt` every time it attaches another article to a cluster, however
// loosely related (see BACKLOG #15), so a story can read as "just now" while
// being weeks old by `firstSeenAt`. Using `updatedAt` here previously
// neutralized the whole point of this decay: a fossil story's age always
// computed near zero.
extension StorySummary {
var clientRank: Double {
let ageHours = max(0.0, -(firstSeenAt ?? updatedAt).timeIntervalSinceNow) / 3600.0
let decay = 1.0 / (1.0 + ageHours / 24.0)
return Double(signalScore) * (0.80 + 0.20 * decay)
}
/// Even with the fix above, this decay is soft (max 20% penalty) a
/// high enough signalScore can still outrank fresher, lower-scored
/// stories. `isFossil` is a hard backstop views use to keep genuinely
/// old stories out of the lead/hero slot regardless of score.
static let fossilThresholdDays: Double = 5
var isFossil: Bool {
let ageSeconds = -(firstSeenAt ?? updatedAt).timeIntervalSinceNow
return ageSeconds > StorySummary.fossilThresholdDays * 86400
}
static func feedOrder(_ a: StorySummary, _ b: StorySummary) -> Bool {
let diff = a.clientRank - b.clientRank
// Stable tiebreak for nearly-equal ranks: newer ID wins (IDs are
// lexicographically monotone from the backend).
if abs(diff) < 0.5 { return a.id > b.id }
return diff > 0
}
}
// MARK: - Story pills (backend-tag driven)
//
// The backend classifies each story into canonical category slugs in `tags[]`.
// The client filters by pill -> slug membership only; no headline keyword
// matching. `topic` is a fallback while the backend finishes emitting `tags[]`.
enum StoryPill: String, CaseIterable, Identifiable {
case all = "All"
case f1 = "F1"
case sport = "Sport"
case tech = "Tech" // AI, Cloud, HomeLab sub-sections inside
case eastAfrica = "East Africa" // Uganda + broader East Africa, Uganda pinned first
case southernAfrica = "Southern Africa" // SA, Namibia, Botswana pinned first
case canada = "Canada"
case unitedStates = "US"
var id: String { rawValue }
var label: String { rawValue }
var slugs: Set<String> {
switch self {
case .all: return []
case .f1: return ["formula-1"]
case .sport: return ["sports", "esports"]
case .tech: return [
"technology", "programming-and-software-development",
"cybersecurity", "security", "privacy-and-data-protection",
"web-design-and-ui-ux", "wordpress-and-web-development",
"artificial-intelligence", "machine-learning", "robotics",
"cloud-computing", "homelab",
]
case .eastAfrica: return ["east-africa", "uganda"]
case .southernAfrica: return [
"south-africa", "namibia", "botswana",
"zimbabwe", "zambia", "mozambique", "southern-africa",
]
case .canada: return ["canada"]
case .unitedStates: return ["united-states"]
}
}
/// Sub-sections to show inside the card layout for this pill (nil = flat).
var subSections: [NewsSection]? {
switch self {
case .tech: return NewsSection.techSubSections
case .eastAfrica: return NewsSection.eastAfricaSubSections
case .southernAfrica: return NewsSection.southernAfricaSubSections
default: return nil
}
}
}
extension StorySummary {
/// Backend `tags[]` are the source of truth; fall back to `topic` during the
/// transition while the backend finishes emitting tags.
var effectiveTags: Set<String> {
let clean = Set((tags ?? []).filter { !$0.isEmpty })
return clean.isEmpty ? [topic] : clean
}
func matches(_ pill: StoryPill) -> Bool {
pill == .all || !effectiveTags.isDisjoint(with: pill.slugs)
}
func inSection(_ section: NewsSection) -> Bool {
!effectiveTags.isDisjoint(with: section.slugs)
}
}
// MARK: - Wordmark
struct JarvisWordmark: View {
var size: CGFloat = 30
private var font: Font { .custom("Courier New", size: size).bold() }
var body: some View {
HStack(spacing: 0) {
Text("j").font(font).foregroundStyle(Palette.orange)
Text("ervis").font(font).foregroundStyle(Palette.primaryText)
}
}
}
// MARK: - News sections (the "All" front page)
//
// Groups the firehose into Apple/Google-News-style sections. A story lands in
// the first section (by this order) whose slugs it carries; `pill` powers "See all".
struct NewsSection: Identifiable {
let id: String
let label: String
let slugs: Set<String>
let pill: StoryPill?
static let sections: [NewsSection] = [
.init(id: "us", label: "US", slugs: ["united-states"], pill: .unitedStates),
.init(id: "world", label: "World", slugs: ["world-news", "news", "news-and-current-affairs"], pill: nil),
.init(id: "politics", label: "Politics", slugs: ["politics", "human-rights", "investigative"], pill: nil),
.init(id: "business", label: "Business", slugs: ["business", "finance", "fintech", "cryptocurrency", "blockchain", "entrepreneur", "startups", "real-estate"], pill: nil),
.init(id: "tech", label: "Tech", slugs: ["technology", "programming-and-software-development", "cybersecurity", "security", "web-design-and-ui-ux", "wordpress-and-web-development", "privacy-and-data-protection", "cloud-computing", "artificial-intelligence", "machine-learning", "robotics", "homelab"], pill: .tech),
.init(id: "east-africa", label: "East Africa", slugs: ["east-africa", "uganda"], pill: .eastAfrica),
.init(id: "southern-africa",label: "Southern Africa", slugs: ["south-africa", "namibia", "botswana", "zimbabwe", "zambia", "mozambique", "southern-africa"], pill: .southernAfrica),
.init(id: "africa", label: "Africa", slugs: ["africa"], pill: nil),
.init(id: "sport", label: "Sport", slugs: ["sports", "esports", "formula-1"], pill: .sport),
.init(id: "science", label: "Science", slugs: ["science", "astronomy", "environment", "green-technology", "sustainability"], pill: nil),
.init(id: "entertainment", label: "Entertainment",slugs: ["entertainment", "music", "pop-culture", "video-game", "arts-and-culture", "books-and-literature"], pill: nil),
.init(id: "health", label: "Health", slugs: ["health", "health-and-wellness", "mental-health"], pill: nil),
.init(id: "lifestyle", label: "Lifestyle", slugs: ["lifestyle", "food-and-drink", "travel", "gardening", "outdoor-and-hiking", "parenting-and-family", "interior-design-and-home-decor", "photography", "self-improvement-and-personal-development", "education", "e-learning"], pill: nil),
]
/// Sub-sections shown inside the Tech pill view.
static let techSubSections: [NewsSection] = [
.init(id: "t-ai", label: "AI", slugs: ["artificial-intelligence", "machine-learning", "robotics"], pill: nil),
.init(id: "t-cloud", label: "Cloud", slugs: ["cloud-computing"], pill: nil),
.init(id: "t-homelab", label: "HomeLab", slugs: ["homelab"], pill: nil),
.init(id: "t-security", label: "Security", slugs: ["cybersecurity", "security", "privacy-and-data-protection"], pill: nil),
.init(id: "t-dev", label: "Dev", slugs: ["programming-and-software-development", "web-design-and-ui-ux", "wordpress-and-web-development"], pill: nil),
.init(id: "t-tech", label: "Technology", slugs: ["technology"], pill: nil),
]
/// Sub-sections shown inside the East Africa pill view Uganda pinned first.
static let eastAfricaSubSections: [NewsSection] = [
.init(id: "ea-uganda", label: "Uganda", slugs: ["uganda"], pill: nil),
.init(id: "ea-kenya", label: "Kenya", slugs: ["east-africa"], pill: nil),
]
/// Sub-sections shown inside the Southern Africa pill SA, Namibia, Botswana first.
static let southernAfricaSubSections: [NewsSection] = [
.init(id: "sa-za", label: "South Africa", slugs: ["south-africa"], pill: nil),
.init(id: "sa-na", label: "Namibia", slugs: ["namibia"], pill: nil),
.init(id: "sa-bw", label: "Botswana", slugs: ["botswana"], pill: nil),
.init(id: "sa-zw", label: "Zimbabwe", slugs: ["zimbabwe"], pill: nil),
.init(id: "sa-zm", label: "Zambia", slugs: ["zambia"], pill: nil),
.init(id: "sa-mz", label: "Mozambique", slugs: ["mozambique"], pill: nil),
]
}

View File

@@ -0,0 +1,320 @@
// 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
@Environment(\.modelContext) private var modelContext
@StateObject private var vm = StoryDetailViewModel()
@Query private var cachedArticles: [CachedArticle]
@Query private var readStories: [ReadStory]
@Query private var savedStories: [SavedStory]
@State private var showShare = false
// 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 isRead: Bool { readStories.contains { $0.id == story.id } }
private var isSaved: Bool { savedStories.contains { $0.id == story.id } }
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 {
Palette.background.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
categoryRow
Text(headline)
.font(.system(size: 31, weight: .bold, design: .default))
.lineSpacing(2)
.foregroundStyle(Palette.primaryText)
.fixedSize(horizontal: false, vertical: true)
if !summary.isEmpty {
Text(summary)
.font(.system(size: 18, weight: .regular, design: .default))
.foregroundStyle(Palette.secondaryText)
.lineSpacing(5)
}
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 }
ToolbarItemGroup(placement: .topBarTrailing) {
Button { showShare = true } label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Color(hex: "888888"))
}
Button { toggleSave() } label: {
Image(systemName: isSaved ? "bookmark.fill" : "bookmark")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(isSaved ? Palette.orange : Color(hex: "888888"))
}
Button { toggleRead() } label: {
Image(systemName: isRead ? "checkmark.circle.fill" : "checkmark.circle")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(isRead ? Palette.orange : Color(hex: "888888"))
}
}
}
.toolbarBackground(Palette.background, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
.task {
markRead()
await vm.load(id: story.id)
}
}
// MARK: - Read / Save / Share
private func markRead() {
let id = story.id
guard (try? modelContext.fetch(
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first == nil
else { return }
modelContext.insert(ReadStory(id: id))
try? modelContext.save()
Task { try? await APIClient.shared.markStoryRead(id: id) }
}
private func toggleRead() {
let id = story.id
if let row = (try? modelContext.fetch(
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
Task { try? await APIClient.shared.markStoryUnread(id: id) }
} else {
modelContext.insert(ReadStory(id: id))
Task { try? await APIClient.shared.markStoryRead(id: id) }
}
try? modelContext.save()
}
private func toggleSave() {
let id = story.id
if let row = (try? modelContext.fetch(
FetchDescriptor<SavedStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
} else {
// Ensure a CachedStory exists so SavedView can display it.
let alreadyCached = (try? modelContext.fetch(
FetchDescriptor<CachedStory>(predicate: #Predicate { $0.id == id })))?.first != nil
if !alreadyCached { modelContext.insert(CachedStory(from: story)) }
modelContext.insert(SavedStory(id: id))
}
try? modelContext.save()
}
private var shareContent: [Any] {
var items: [Any] = ["\(headline)\n\n\(summary)\n\nvia Jarvis"]
if let first = story.sources.first, let url = URL(string: first.url) {
items.append(url)
}
return items
}
// 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(Palette.mutedText)
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: 16, weight: .regular, design: .default))
.foregroundStyle(Palette.secondaryText)
.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(Palette.tertiaryText)
.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(Palette.tertiaryText)
} 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 : Palette.mutedText)
.frame(width: 11, height: 11)
.overlay(Circle().stroke(Palette.background, lineWidth: 2))
if !isLast {
Rectangle().fill(Palette.hairline).frame(width: 1.5)
}
}
.frame(width: 11)
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 8) {
Text(entry.source)
.font(.system(size: 14, weight: .semibold, design: .default))
.foregroundStyle(isFirst ? Palette.orange : Palette.primaryText)
if entry.isBreaking {
Text("BREAKING")
.font(.system(size: 9, weight: .bold, design: .monospaced))
.foregroundStyle(Palette.primaryText)
.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(Palette.tertiaryText)
if cached { CachedDot(size: 6) }
}
Text(entry.headline)
.font(.system(size: 16, weight: .regular, design: .default))
.foregroundStyle(Palette.secondaryText)
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
}
.padding(.bottom, isLast ? 0 : 20)
}
.contentShape(Rectangle())
}
}