Initial commit: Jarvis iOS app
SwiftUI client for a self-hosted RSS news-correlation platform: signal feed, story detail, article reader, feed manager, and LAN⇄Tailscale connectivity. Project generated from project.yml via XcodeGen. Includes CI build matrix (macOS 14/15 × Debug/Release), issue templates, backlog, and API/backend handoff docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
114
Jarvis/Views/Feeds/AddFeedSheet.swift
Normal file
114
Jarvis/Views/Feeds/AddFeedSheet.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
// AddFeedSheet.swift
|
||||
// Jarvis — add a new RSS feed: URL + name + confirm.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AddFeedSheet: View {
|
||||
/// Returns true on success.
|
||||
let onAdd: (String, String) async -> Bool
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var url = ""
|
||||
@State private var name = ""
|
||||
@State private var isSubmitting = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
private var canSubmit: Bool {
|
||||
!url.trimmingCharacters(in: .whitespaces).isEmpty &&
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty &&
|
||||
!isSubmitting
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Add feed")
|
||||
.font(.system(size: 22, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
Spacer()
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundStyle(Color(hex: "888888"))
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
field(label: "FEED URL", text: $url,
|
||||
placeholder: "https://example.com/rss",
|
||||
mono: true, keyboard: .URL)
|
||||
.padding(.top, 12)
|
||||
|
||||
field(label: "DISPLAY NAME", text: $name,
|
||||
placeholder: "Example News",
|
||||
mono: false, keyboard: .default)
|
||||
.padding(.top, 16)
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Color(hex: "C25555"))
|
||||
.padding(.top, 14)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await submit() }
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
if isSubmitting {
|
||||
ProgressView().tint(.black)
|
||||
} else {
|
||||
Text("Add feed").font(.system(size: 16, weight: .bold)).foregroundStyle(.black)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.frame(height: 52)
|
||||
.background(canSubmit ? Palette.orange : Palette.surface2)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.disabled(!canSubmit)
|
||||
.padding(.top, 28)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.presentationDetents([.medium])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
private func field(label: String, text: Binding<String>, placeholder: String,
|
||||
mono: Bool, keyboard: UIKeyboardType) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(label)
|
||||
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
||||
.kerning(0.8)
|
||||
.foregroundStyle(Color(hex: "666666"))
|
||||
TextField("", text: text,
|
||||
prompt: Text(placeholder).foregroundColor(Color(hex: "444444")))
|
||||
.font(.system(size: 15, weight: .regular, design: mono ? .monospaced : .default))
|
||||
.foregroundStyle(mono ? Palette.orange : .white)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.keyboardType(keyboard)
|
||||
.padding(14)
|
||||
.background(Palette.surface)
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Palette.surface2, lineWidth: 1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
private func submit() async {
|
||||
isSubmitting = true
|
||||
errorMessage = nil
|
||||
let ok = await onAdd(url.trimmingCharacters(in: .whitespaces),
|
||||
name.trimmingCharacters(in: .whitespaces))
|
||||
isSubmitting = false
|
||||
if ok { dismiss() } else { errorMessage = "Couldn’t add feed. Check the URL and try again." }
|
||||
}
|
||||
}
|
||||
329
Jarvis/Views/Feeds/FeedManagerView.swift
Normal file
329
Jarvis/Views/Feeds/FeedManagerView.swift
Normal file
@@ -0,0 +1,329 @@
|
||||
// FeedManagerView.swift
|
||||
// Jarvis — feed health & management. Healthy vs. needs-attention, live health
|
||||
// updates over WebSocket, swipe to delete, add-feed sheet.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class FeedManagerViewModel: ObservableObject {
|
||||
@Published var feeds: [Feed] = []
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
private let api = APIClient.shared
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
feeds = try await api.fetchFeeds()
|
||||
} catch let e as APIError {
|
||||
error = e.errorDescription
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func add(url: String, name: String) async -> Bool {
|
||||
do {
|
||||
let feed = try await api.addFeed(url: url, name: name)
|
||||
feeds.append(feed)
|
||||
return true
|
||||
} catch let e as APIError {
|
||||
error = e.errorDescription
|
||||
return false
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func delete(_ feed: Feed) async {
|
||||
// Optimistic removal; restore on failure.
|
||||
let snapshot = feeds
|
||||
feeds.removeAll { $0.id == feed.id }
|
||||
do {
|
||||
try await api.deleteFeed(id: feed.id)
|
||||
} catch {
|
||||
feeds = snapshot
|
||||
self.error = (error as? APIError)?.errorDescription ?? error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a live `feed.health` WebSocket event.
|
||||
func applyHealth(_ event: WSEvent) {
|
||||
guard let id = event.feedId, let health = event.health,
|
||||
let idx = feeds.firstIndex(where: { $0.id == id }) else { return }
|
||||
let f = feeds[idx]
|
||||
feeds[idx] = Feed(
|
||||
id: f.id, name: f.name, url: f.url, health: health,
|
||||
pollIntervalSeconds: f.pollIntervalSeconds,
|
||||
failureCount: event.failureCount ?? f.failureCount,
|
||||
lastFetchedAt: f.lastFetchedAt, articleCountToday: f.articleCountToday
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct FeedManagerView: View {
|
||||
@EnvironmentObject var ws: WebSocketManager
|
||||
@EnvironmentObject var settings: ServerSettings
|
||||
@StateObject private var vm = FeedManagerViewModel()
|
||||
|
||||
@State private var query = ""
|
||||
@State private var showAdd = false
|
||||
@State private var showConnectivity = false
|
||||
|
||||
private var filtered: [Feed] {
|
||||
guard !query.isEmpty else { return vm.feeds }
|
||||
return vm.feeds.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(query) ||
|
||||
$0.url.localizedCaseInsensitiveContains(query)
|
||||
}
|
||||
}
|
||||
private var healthy: [Feed] { filtered.filter { $0.health == .active } }
|
||||
private var attention: [Feed] { filtered.filter { $0.health != .active } }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
serverCard
|
||||
searchBar
|
||||
feedList
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.presentationDragIndicator(.visible)
|
||||
.sheet(isPresented: $showAdd) {
|
||||
AddFeedSheet { url, name in
|
||||
await vm.add(url: url, name: name)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showConnectivity) {
|
||||
ConnectivityView()
|
||||
}
|
||||
.task { await vm.load() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .feedHealthChanged)) { note in
|
||||
if let event = note.object as? WSEvent { vm.applyHealth(event) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center) {
|
||||
JarvisWordmark(size: 26)
|
||||
Spacer()
|
||||
Button { showAdd = true } label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "plus").font(.system(size: 13, weight: .heavy))
|
||||
Text("Add feed").font(.system(size: 14, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(.black)
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 36)
|
||||
.background(Palette.orange)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
|
||||
// MARK: - Platform connection card
|
||||
|
||||
private var serverCard: some View {
|
||||
Button { showConnectivity = true } label: {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("PLATFORM")
|
||||
.font(.system(size: 9, weight: .bold, design: .monospaced))
|
||||
.kerning(0.8)
|
||||
.foregroundStyle(Color(hex: "555555"))
|
||||
Text(settings.host ?? "not configured")
|
||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(Palette.orange)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(ws.connectionState.dotColor).frame(width: 7, height: 7)
|
||||
Text(ws.connectionState.label.uppercased())
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.kerning(0.6)
|
||||
.foregroundStyle(ws.connectionState.isLive ? Color(hex: "33C25E") : Color(hex: "888888"))
|
||||
}
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(Color(hex: "555555"))
|
||||
}
|
||||
.padding(14)
|
||||
.background(Palette.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
|
||||
// MARK: - Search
|
||||
|
||||
private var searchBar: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Color(hex: "555555"))
|
||||
TextField("", text: $query, prompt: Text("Search feeds").foregroundColor(Color(hex: "555555")))
|
||||
.font(.system(size: 15, weight: .regular))
|
||||
.foregroundStyle(.white)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 44)
|
||||
.background(Palette.surface2)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
// MARK: - List
|
||||
|
||||
private var feedList: some View {
|
||||
List {
|
||||
if vm.isLoading && vm.feeds.isEmpty {
|
||||
loadingRow
|
||||
}
|
||||
if !healthy.isEmpty {
|
||||
section(title: "HEALTHY", feeds: healthy)
|
||||
}
|
||||
if !attention.isEmpty {
|
||||
section(title: "NEEDS ATTENTION", feeds: attention)
|
||||
}
|
||||
if !vm.isLoading && filtered.isEmpty {
|
||||
emptyRow
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.black)
|
||||
.refreshable { await vm.load() }
|
||||
}
|
||||
|
||||
private func section(title: String, feeds: [Feed]) -> some View {
|
||||
Section {
|
||||
ForEach(feeds) { feed in
|
||||
FeedRowView(feed: feed)
|
||||
.listRowBackground(Color.black)
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
|
||||
.listRowSeparatorTint(Palette.hairline)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
Task { await vm.delete(feed) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(title)
|
||||
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
||||
.kerning(0.8)
|
||||
.foregroundStyle(Color(hex: "555555"))
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 6, trailing: 16))
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingRow: some View {
|
||||
ProgressView().tint(Palette.orange)
|
||||
.frame(maxWidth: .infinity)
|
||||
.listRowBackground(Color.black)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
|
||||
private var emptyRow: some View {
|
||||
Text(query.isEmpty ? (vm.error ?? "No feeds configured.") : "No feeds match “\(query)”.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Color(hex: "555555"))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.listRowBackground(Color.black)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Feed row
|
||||
|
||||
struct FeedRowView: View {
|
||||
let feed: Feed
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "dot.radiowaves.up.forward")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: "777777"))
|
||||
.frame(width: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(feed.name)
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
Text(metaLine)
|
||||
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(Color(hex: "666666"))
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
healthDot
|
||||
}
|
||||
.frame(minHeight: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var metaLine: String {
|
||||
let interval = pollText(feed.pollIntervalSeconds)
|
||||
let base = "Polls \(interval) · \(feed.articleCountToday) today"
|
||||
if feed.health == .failing {
|
||||
return base + " · ⚠ \(retryText)"
|
||||
}
|
||||
if feed.health == .dead {
|
||||
return base + " · dead"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
private var retryText: String {
|
||||
guard let last = feed.lastFetchedAt else { return "retrying" }
|
||||
let next = last.addingTimeInterval(Double(feed.pollIntervalSeconds))
|
||||
let secs = Int(next.timeIntervalSinceNow)
|
||||
return secs > 0 ? "retry \(secs)s" : "retrying…"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var healthDot: some View {
|
||||
// Live-updating countdown for failing feeds.
|
||||
if feed.health == .failing {
|
||||
TimelineView(.periodic(from: .now, by: 1)) { _ in
|
||||
dot(color: Palette.healthFailing)
|
||||
}
|
||||
} else {
|
||||
dot(color: feed.health == .active ? Palette.healthActive : Palette.healthDead)
|
||||
}
|
||||
}
|
||||
|
||||
private func dot(color: Color) -> some View {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 10, height: 10)
|
||||
.overlay(Circle().stroke(color.opacity(0.9), lineWidth: 3).blur(radius: 2))
|
||||
}
|
||||
|
||||
private func pollText(_ secs: Int) -> String {
|
||||
if secs % 3600 == 0 { return "every \(secs / 3600) hr" }
|
||||
if secs >= 60 { return "every \(secs / 60) min" }
|
||||
return "every \(secs) s"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user