// 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" } }