Files
jarvis/Jarvis/Views/Feeds/FeedManagerView.swift
Robin Kutesa 27d0e22767 Initial commit: Jarvis iOS app
SwiftUI client for a self-hosted RSS news-correlation platform: signal feed,
story detail, article reader, feed manager, and LAN⇄Tailscale connectivity.
Project generated from project.yml via XcodeGen.

Includes CI build matrix (macOS 14/15 × Debug/Release), issue templates,
backlog, and API/backend handoff docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:04:59 +03:00

330 lines
11 KiB
Swift

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