rename: Jarvis -> Jervis target, scheme, and folder throughout
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:
20
Jervis/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
20
Jervis/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x00",
|
||||
"green" : "0x5C",
|
||||
"red" : "0xFF"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Jervis/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
Jervis/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 442 KiB |
14
Jervis/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
14
Jervis/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
Jervis/Assets.xcassets/Contents.json
Normal file
6
Jervis/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
21
Jervis/Assets.xcassets/JervisIcon.imageset/Contents.json
vendored
Normal file
21
Jervis/Assets.xcassets/JervisIcon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "JervisIcon.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Jervis/Assets.xcassets/JervisIcon.imageset/JervisIcon.png
vendored
Normal file
BIN
Jervis/Assets.xcassets/JervisIcon.imageset/JervisIcon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 442 KiB |
38
Jervis/Assets.xcassets/KisaniOrange.colorset/Contents.json
Normal file
38
Jervis/Assets.xcassets/KisaniOrange.colorset/Contents.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x00",
|
||||
"green" : "0x5C",
|
||||
"red" : "0xFF"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x00",
|
||||
"green" : "0x5C",
|
||||
"red" : "0xFF"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
113
Jervis/Connectivity/ConnectivityManager.swift
Normal file
113
Jervis/Connectivity/ConnectivityManager.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
// ConnectivityManager.swift
|
||||
// Jarvis — resolves which endpoint to use by probing /health, activates it on the
|
||||
// APIClient + WebSocket, and re-resolves when the network path changes.
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
enum Reachability: Equatable {
|
||||
case unknown, checking, reachable, unreachable
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ConnectivityManager: ObservableObject {
|
||||
static let shared = ConnectivityManager()
|
||||
|
||||
/// The endpoint currently in use (nil = nothing reachable).
|
||||
@Published var activeHost: String?
|
||||
/// Per-host probe results, for the settings UI badges.
|
||||
@Published var status: [String: Reachability] = [:]
|
||||
@Published var isResolving = false
|
||||
|
||||
private let settings = ConnectivitySettings.shared
|
||||
private let probeSession: URLSession
|
||||
private var monitor: NWPathMonitor?
|
||||
private var lastActivated: String?
|
||||
private var resolveTask: Task<Void, Never>?
|
||||
|
||||
private init() {
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.timeoutIntervalForRequest = 2.5
|
||||
cfg.waitsForConnectivity = false
|
||||
probeSession = URLSession(configuration: cfg)
|
||||
}
|
||||
|
||||
/// Begin watching the network path; re-resolve (debounced) on changes.
|
||||
func start() {
|
||||
guard monitor == nil else { return }
|
||||
let m = NWPathMonitor()
|
||||
m.pathUpdateHandler = { [weak self] _ in
|
||||
Task { @MainActor in self?.scheduleResolve() }
|
||||
}
|
||||
m.start(queue: DispatchQueue(label: "jarvis.connectivity"))
|
||||
monitor = m
|
||||
}
|
||||
|
||||
private func scheduleResolve() {
|
||||
guard settings.autoConnect else { return } // only auto-switch when enabled
|
||||
resolveTask?.cancel()
|
||||
let delay = UInt64(max(1, settings.switchDelaySeconds)) * 1_000_000_000
|
||||
resolveTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: delay)
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.resolveAndActivate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe candidates in priority order; activate the first that answers.
|
||||
func resolveAndActivate() async {
|
||||
guard !isResolving else { return }
|
||||
let candidates = settings.candidates
|
||||
guard !candidates.isEmpty else { return }
|
||||
isResolving = true
|
||||
defer { isResolving = false }
|
||||
|
||||
for host in candidates {
|
||||
status[host] = .checking
|
||||
if await probe(host) {
|
||||
status[host] = .reachable
|
||||
await activate(host)
|
||||
return
|
||||
} else {
|
||||
status[host] = .unreachable
|
||||
}
|
||||
}
|
||||
// Nothing reachable.
|
||||
activeHost = nil
|
||||
}
|
||||
|
||||
/// Probe a single host and record its status (used by the settings screen).
|
||||
@discardableResult
|
||||
func check(_ host: String) async -> Bool {
|
||||
let h = host.trimmingCharacters(in: .whitespaces)
|
||||
guard !h.isEmpty else { return false }
|
||||
status[h] = .checking
|
||||
let ok = await probe(h)
|
||||
status[h] = ok ? .reachable : .unreachable
|
||||
return ok
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func probe(_ host: String) async -> Bool {
|
||||
guard let url = URL(string: "http://\(host)/api/v1/health") else { return false }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "GET"
|
||||
do {
|
||||
let (_, response) = try await probeSession.data(for: req)
|
||||
return (response as? HTTPURLResponse).map { (200...299).contains($0.statusCode) } ?? false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func activate(_ host: String) async {
|
||||
activeHost = host
|
||||
guard host != lastActivated else { return } // avoid thrashing the WS
|
||||
lastActivated = host
|
||||
await ServerSettings.shared.activate(host: host)
|
||||
// Full paginated load — page 1 only misses unread stories on pages 2–5
|
||||
// that may have arrived since last session.
|
||||
await StoryStore.shared.loadFeedFull()
|
||||
}
|
||||
}
|
||||
73
Jervis/Connectivity/ConnectivitySettings.swift
Normal file
73
Jervis/Connectivity/ConnectivitySettings.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
// ConnectivitySettings.swift
|
||||
// Jarvis — persisted connection profiles: a Direct/LAN address and a Remote/VPN
|
||||
// address for the same server, the chosen provider, and the auto-switch policy.
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ConnectivitySettings: ObservableObject {
|
||||
static let shared = ConnectivitySettings()
|
||||
|
||||
private let key = "jarvis_connectivity_v2"
|
||||
|
||||
@Published var provider: VPNProvider { didSet { persist() } }
|
||||
@Published var directHost: String { didSet { persist() } } // LAN, e.g. 192.168.30.50:8080
|
||||
@Published var remoteHost: String { didSet { persist() } } // Tailscale/VPN address
|
||||
/// "Use <provider> when remote" — when off, only the LAN address is ever used.
|
||||
@Published var remoteEnabled: Bool { didSet { persist() } }
|
||||
/// "Auto-connect" — re-resolve automatically when the network path changes.
|
||||
@Published var autoConnect: Bool { didSet { persist() } }
|
||||
/// Debounce before switching endpoints after a network change (seconds).
|
||||
@Published var switchDelaySeconds: Int { didSet { persist() } }
|
||||
|
||||
private struct Stored: Codable {
|
||||
var provider: VPNProvider
|
||||
var directHost: String
|
||||
var remoteHost: String
|
||||
var remoteEnabled: Bool?
|
||||
var autoConnect: Bool?
|
||||
var switchDelaySeconds: Int?
|
||||
}
|
||||
|
||||
private init() {
|
||||
if let data = UserDefaults.standard.data(forKey: key),
|
||||
let s = try? JSONDecoder().decode(Stored.self, from: data) {
|
||||
provider = s.provider
|
||||
directHost = s.directHost
|
||||
remoteHost = s.remoteHost
|
||||
remoteEnabled = s.remoteEnabled ?? true
|
||||
autoConnect = s.autoConnect ?? true
|
||||
switchDelaySeconds = s.switchDelaySeconds ?? 5
|
||||
} else {
|
||||
provider = .tailscale
|
||||
directHost = UserDefaults.standard.string(forKey: "jarvis_server_host") ?? ""
|
||||
remoteHost = ""
|
||||
remoteEnabled = true
|
||||
autoConnect = true
|
||||
switchDelaySeconds = 5
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
let s = Stored(provider: provider, directHost: directHost, remoteHost: remoteHost,
|
||||
remoteEnabled: remoteEnabled, autoConnect: autoConnect,
|
||||
switchDelaySeconds: switchDelaySeconds)
|
||||
if let data = try? JSONEncoder().encode(s) {
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the remote endpoint participates in resolution right now.
|
||||
var remoteActiveInPolicy: Bool { provider.needsRemote && remoteEnabled }
|
||||
|
||||
/// Hosts to probe, in priority order (LAN first), de-duplicated and trimmed.
|
||||
var candidates: [String] {
|
||||
let d = directHost.trimmingCharacters(in: .whitespaces)
|
||||
let r = remoteActiveInPolicy ? remoteHost.trimmingCharacters(in: .whitespaces) : ""
|
||||
var seen = Set<String>(); var out: [String] = []
|
||||
for h in [d, r] where !h.isEmpty && !seen.contains(h) { seen.insert(h); out.append(h) }
|
||||
return out
|
||||
}
|
||||
|
||||
var isConfigured: Bool { !candidates.isEmpty }
|
||||
}
|
||||
101
Jervis/Connectivity/VPNProvider.swift
Normal file
101
Jervis/Connectivity/VPNProvider.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
// VPNProvider.swift
|
||||
// Jarvis — remote-access providers. The app can't start another app's VPN tunnel
|
||||
// (iOS sandboxing), so the provider only tailors hints, help text, and an
|
||||
// "open the app" jump. Reachability + endpoint selection is provider-agnostic.
|
||||
|
||||
import UIKit
|
||||
|
||||
enum VPNProvider: String, CaseIterable, Codable, Identifiable {
|
||||
case direct // LAN only, no VPN
|
||||
case tailscale
|
||||
case headscale // self-hosted control plane, Tailscale clients
|
||||
case netbird
|
||||
case zerotier
|
||||
case netmaker
|
||||
case wireguard
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .direct: return "Direct (LAN)"
|
||||
case .tailscale: return "Tailscale"
|
||||
case .headscale: return "Headscale"
|
||||
case .netbird: return "NetBird"
|
||||
case .zerotier: return "ZeroTier"
|
||||
case .netmaker: return "Netmaker"
|
||||
case .wireguard: return "WireGuard"
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a separate remote/VPN address is meaningful for this provider.
|
||||
var needsRemote: Bool { self != .direct }
|
||||
|
||||
/// Placeholder shown in the remote-address field.
|
||||
var remoteHint: String {
|
||||
switch self {
|
||||
case .direct: return "—"
|
||||
case .tailscale: return "jarvis.tailnet.ts.net:8080 or 100.x.y.z:8080"
|
||||
case .headscale: return "100.x.y.z:8080 (MagicDNS or tailnet IP)"
|
||||
case .netbird: return "100.x.y.z:8080"
|
||||
case .zerotier: return "10.x.x.x:8080 (managed IP)"
|
||||
case .netmaker: return "10.x.x.x:8080"
|
||||
case .wireguard: return "10.0.0.x:8080 (peer IP)"
|
||||
}
|
||||
}
|
||||
|
||||
var blurb: String {
|
||||
switch self {
|
||||
case .direct:
|
||||
return "Use the LAN address only. Best when the phone is always on the home network."
|
||||
case .tailscale:
|
||||
return "On your home wifi Jarvis uses the LAN address directly; away, it uses your Tailscale address. Make sure Tailscale is connected when remote."
|
||||
case .headscale:
|
||||
return "Self-hosted Tailscale control plane. Same client behaviour as Tailscale — keep the client connected when away."
|
||||
case .netbird:
|
||||
return "Open-source WireGuard mesh. Keep the NetBird app connected when off the LAN."
|
||||
case .zerotier:
|
||||
return "Managed network via ZeroTier. Keep the ZeroTier app joined to the network when remote."
|
||||
case .netmaker:
|
||||
return "Self-hosted WireGuard mesh. Keep your Netmaker client up when off the LAN."
|
||||
case .wireguard:
|
||||
return "Plain WireGuard tunnel. Enable the tunnel in the WireGuard app when remote."
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort URL scheme + App Store fallback used by "Open <provider>".
|
||||
/// iOS can't start the tunnel for us — this just brings the app forward.
|
||||
private var urlScheme: URL? {
|
||||
switch self {
|
||||
case .tailscale, .headscale: return URL(string: "tailscale://")
|
||||
case .zerotier: return URL(string: "zerotier://")
|
||||
case .wireguard: return URL(string: "wireguard://")
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var appStoreURL: URL? {
|
||||
switch self {
|
||||
case .tailscale, .headscale: return URL(string: "https://apps.apple.com/app/id1470499037")
|
||||
case .zerotier: return URL(string: "https://apps.apple.com/app/id1084101492")
|
||||
case .wireguard: return URL(string: "https://apps.apple.com/app/id1441195209")
|
||||
case .netbird: return URL(string: "https://apps.apple.com/app/id6443155627")
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var canOpenApp: Bool { urlScheme != nil || appStoreURL != nil }
|
||||
|
||||
/// Try the app's scheme; fall back to its App Store page.
|
||||
@MainActor
|
||||
func openApp() {
|
||||
let candidates = [urlScheme, appStoreURL].compactMap { $0 }
|
||||
func attempt(_ i: Int) {
|
||||
guard i < candidates.count else { return }
|
||||
UIApplication.shared.open(candidates[i]) { ok in
|
||||
if !ok { attempt(i + 1) }
|
||||
}
|
||||
}
|
||||
attempt(0)
|
||||
}
|
||||
}
|
||||
58
Jervis/Info.plist
Normal file
58
Jervis/Info.plist
Normal file
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.kisani.jarvis.briefing.refresh</string>
|
||||
<string>com.kisani.jarvis.feed.refresh</string>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Jervis</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>tailscale</string>
|
||||
<string>zerotier</string>
|
||||
<string>wireguard</string>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Jervis connects to your self-hosted news platform on the local network.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string></string>
|
||||
</dict>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
150
Jervis/JarvisApp.swift
Normal file
150
Jervis/JarvisApp.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
// JarvisApp.swift
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit // needed for AppDelegate
|
||||
import BackgroundTasks
|
||||
|
||||
// MARK: - Splash
|
||||
|
||||
struct SplashView: View {
|
||||
var onDismiss: () -> Void
|
||||
|
||||
@State private var iconScale: CGFloat = 0.82
|
||||
@State private var iconOpacity: Double = 0
|
||||
@State private var markOpacity: Double = 0
|
||||
@State private var rootOpacity: Double = 1
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(hex: "0A0A0A").ignoresSafeArea()
|
||||
VStack(spacing: 22) {
|
||||
Image("JervisIcon")
|
||||
.resizable()
|
||||
.frame(width: 108, height: 108)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: Palette.orange.opacity(0.35), radius: 22, y: 4)
|
||||
.scaleEffect(iconScale)
|
||||
.opacity(iconOpacity)
|
||||
|
||||
JarvisWordmark(size: 28)
|
||||
.opacity(markOpacity)
|
||||
}
|
||||
}
|
||||
.opacity(rootOpacity)
|
||||
.onAppear {
|
||||
withAnimation(.spring(response: 0.48, dampingFraction: 0.74).delay(0.08)) {
|
||||
iconScale = 1
|
||||
iconOpacity = 1
|
||||
}
|
||||
withAnimation(.easeOut(duration: 0.28).delay(0.32)) {
|
||||
markOpacity = 1
|
||||
}
|
||||
withAnimation(.easeIn(duration: 0.26).delay(1.6)) {
|
||||
rootOpacity = 0
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.9) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
// Briefing notifications refresh
|
||||
BGTaskScheduler.shared.register(forTaskWithIdentifier: jarvisBriefingRefreshID, using: nil) { task in
|
||||
Task { @MainActor in
|
||||
await NotificationManager.shared.applySettings()
|
||||
NotificationManager.shared.scheduleBackgroundRefresh()
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
}
|
||||
// Story feed cache refresh
|
||||
BGTaskScheduler.shared.register(forTaskWithIdentifier: jarvisFeedRefreshID, using: nil) { task in
|
||||
guard let refresh = task as? BGAppRefreshTask else {
|
||||
task.setTaskCompleted(success: false); return
|
||||
}
|
||||
BackgroundRefreshManager.handleFeedRefresh(task: refresh)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct JarvisApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .dark
|
||||
@StateObject private var settings = ServerSettings.shared
|
||||
@StateObject private var store = StoryStore.shared
|
||||
@StateObject private var ws = WebSocketManager.shared
|
||||
@StateObject private var connectivity = ConnectivitySettings.shared
|
||||
@StateObject private var connManager = ConnectivityManager.shared
|
||||
@StateObject private var notifications = NotificationManager.shared
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var splashDone = false
|
||||
|
||||
// Single ModelContainer instance shared with BackgroundRefreshManager.
|
||||
private let container: ModelContainer = {
|
||||
let schema = Schema([CachedStory.self, CachedArticle.self,
|
||||
ReadStory.self, SavedStory.self, SeenStory.self])
|
||||
return try! ModelContainer(for: schema)
|
||||
}()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ZStack {
|
||||
// Main app always rendered underneath so it's ready when splash fades.
|
||||
Group {
|
||||
if settings.isConfigured {
|
||||
RootTabView()
|
||||
} else {
|
||||
OnboardingView()
|
||||
}
|
||||
}
|
||||
|
||||
if !splashDone {
|
||||
SplashView { splashDone = true }
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(appearanceMode.colorScheme)
|
||||
.environmentObject(settings)
|
||||
.environmentObject(store)
|
||||
.environmentObject(ws)
|
||||
.environmentObject(connectivity)
|
||||
.environmentObject(connManager)
|
||||
.environmentObject(notifications)
|
||||
.task {
|
||||
BackgroundRefreshManager.container = container
|
||||
BackgroundRefreshManager.scheduleFeedRefresh()
|
||||
// Pre-populate the feed from SwiftData cache before the first API
|
||||
// fetch completes — prevents the cold-launch empty state.
|
||||
store.bootstrap(container: container)
|
||||
connManager.start()
|
||||
await connManager.resolveAndActivate()
|
||||
await notifications.bootstrap()
|
||||
store.startForegroundSync()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
switch phase {
|
||||
case .active:
|
||||
store.startForegroundSync()
|
||||
// Only trigger a load if connectivity has already been resolved.
|
||||
// On cold launch activeHost is nil until resolveAndActivate()
|
||||
// completes — calling loadFeedFull() before that always throws
|
||||
// APIError.notConnected and leaves lastSyncedAt permanently nil.
|
||||
// activate() calls loadFeedFull() itself once the host is confirmed.
|
||||
if connManager.activeHost != nil {
|
||||
Task { await store.loadFeedFull() }
|
||||
}
|
||||
case .background:
|
||||
store.stopForegroundSync()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
.modelContainer(container)
|
||||
}
|
||||
}
|
||||
272
Jervis/Models/Models.swift
Normal file
272
Jervis/Models/Models.swift
Normal file
@@ -0,0 +1,272 @@
|
||||
// Models.swift
|
||||
// Jarvis — all Codable structs matching the API contract
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Signal Score
|
||||
|
||||
struct SignalScore: Codable, Hashable {
|
||||
let total: Int
|
||||
let sourceAuthority: Int
|
||||
let freshness: Int
|
||||
let localRelevance: Int
|
||||
let crossSourceConfirmation: Int
|
||||
let topicImportance: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case total = "signalScore"
|
||||
case sourceAuthority, freshness, localRelevance
|
||||
case crossSourceConfirmation, topicImportance
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source
|
||||
|
||||
struct StorySource: Codable, Hashable, Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let url: String
|
||||
let publishedAt: Date
|
||||
let isBreaking: Bool
|
||||
}
|
||||
|
||||
// MARK: - Timeline Entry
|
||||
|
||||
struct TimelineEntry: Codable, Hashable, Identifiable {
|
||||
let articleId: String
|
||||
let source: String
|
||||
let headline: String
|
||||
let publishedAt: Date
|
||||
let isBreaking: Bool
|
||||
|
||||
var id: String { articleId }
|
||||
}
|
||||
|
||||
// MARK: - Story Summary (list view)
|
||||
|
||||
struct StorySummary: Codable, Identifiable, Hashable {
|
||||
let id: String
|
||||
let headline: String
|
||||
let summary: String
|
||||
let topic: String
|
||||
/// Canonical category slugs from the backend. Optional so decoding tolerates
|
||||
/// the transition while the backend rolls out `tags[]`; falls back to `topic`.
|
||||
let tags: [String]?
|
||||
let signalScore: Int
|
||||
let scoreBreakdown: ScoreBreakdown
|
||||
let sourceCount: Int
|
||||
let sources: [StorySource]
|
||||
let consensus: String?
|
||||
let conflict: String?
|
||||
let updatedAt: Date
|
||||
let createdAt: Date
|
||||
/// When the cluster first appeared. `updatedAt` bumps on every article the
|
||||
/// backend attaches to the cluster (even a loosely-related one), so it can
|
||||
/// read "1 hr ago" on stories that are actually weeks old — `firstSeenAt`
|
||||
/// is the honest age. Optional so decoding tolerates older API responses.
|
||||
let firstSeenAt: Date?
|
||||
}
|
||||
|
||||
struct ScoreBreakdown: Codable, Hashable {
|
||||
let sourceAuthority: Int
|
||||
let freshness: Int
|
||||
let localRelevance: Int
|
||||
let crossSourceConfirmation: Int
|
||||
let topicImportance: Int
|
||||
}
|
||||
|
||||
// MARK: - Story Detail (full, with timeline)
|
||||
|
||||
struct StoryDetail: Codable, Identifiable {
|
||||
let id: String
|
||||
let headline: String
|
||||
let summary: String
|
||||
let topic: String
|
||||
let signalScore: Int
|
||||
let scoreBreakdown: ScoreBreakdown
|
||||
let sourceCount: Int
|
||||
let consensus: String?
|
||||
let conflict: String?
|
||||
let timeline: [TimelineEntry]
|
||||
let updatedAt: Date
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
// MARK: - Article (full content, for offline cache)
|
||||
|
||||
struct Article: Codable, Identifiable {
|
||||
let id: String
|
||||
let storyId: String
|
||||
let source: String
|
||||
let sourceUrl: String
|
||||
let headline: String
|
||||
let body: String
|
||||
let imageUrl: String?
|
||||
let author: String?
|
||||
let publishedAt: Date
|
||||
}
|
||||
|
||||
// MARK: - Feed
|
||||
|
||||
struct Feed: Codable, Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let url: String
|
||||
let health: FeedHealth
|
||||
let pollIntervalSeconds: Int
|
||||
let failureCount: Int
|
||||
let lastFetchedAt: Date?
|
||||
let articleCountToday: Int
|
||||
}
|
||||
|
||||
enum FeedHealth: String, Codable {
|
||||
case active, failing, dead
|
||||
}
|
||||
|
||||
// MARK: - Server Health
|
||||
|
||||
struct ServerHealth: Codable {
|
||||
let status: String
|
||||
let version: String
|
||||
let storiesCount: Int
|
||||
let feedsCount: Int
|
||||
let uptime: Int
|
||||
}
|
||||
|
||||
// MARK: - Paginated Response
|
||||
|
||||
struct PaginatedStories: Codable {
|
||||
let data: [StorySummary]
|
||||
let nextCursor: String?
|
||||
let hasMore: Bool
|
||||
let total: Int
|
||||
let unreadCount: Int?
|
||||
let readSuppressedCount: Int?
|
||||
let lastReadSyncAt: Date?
|
||||
let activePill: String?
|
||||
}
|
||||
|
||||
// MARK: - WebSocket Events
|
||||
|
||||
enum WSEventType: String, Codable {
|
||||
case storyUpdated = "story.updated"
|
||||
case storyCreated = "story.created"
|
||||
case storyStale = "story.stale"
|
||||
case feedHealth = "feed.health"
|
||||
case ping, pong
|
||||
}
|
||||
|
||||
struct WSEvent: Codable {
|
||||
let type: WSEventType
|
||||
let storyId: String?
|
||||
let feedId: String?
|
||||
let signalScore: Int?
|
||||
let sourceCount: Int?
|
||||
let headline: String?
|
||||
let topic: String?
|
||||
let health: FeedHealth?
|
||||
let failureCount: Int?
|
||||
let updatedAt: Date?
|
||||
let createdAt: Date?
|
||||
}
|
||||
|
||||
// MARK: - SwiftData cached models
|
||||
|
||||
@Model
|
||||
final class CachedStory {
|
||||
@Attribute(.unique) var id: String
|
||||
var headline: String
|
||||
var summary: String
|
||||
var topic: String
|
||||
/// Canonical backend tags, so offline pill filtering stays accurate.
|
||||
/// Defaulted for lightweight migration of existing cached rows.
|
||||
var tags: [String] = []
|
||||
var signalScore: Int
|
||||
var sourceCount: Int
|
||||
var consensus: String?
|
||||
var conflict: String?
|
||||
var updatedAt: Date
|
||||
/// Original publish timestamp. Defaults to updatedAt for rows cached before
|
||||
/// this field was added — SwiftData handles the automatic migration.
|
||||
var createdAt: Date = Date()
|
||||
/// When the cluster first appeared — the honest age, unlike `updatedAt` which
|
||||
/// bumps whenever the backend attaches another article. Defaults to `updatedAt`
|
||||
/// for rows cached before this field was added.
|
||||
var firstSeenAt: Date = Date()
|
||||
var cachedAt: Date
|
||||
|
||||
init(from summary: StorySummary) {
|
||||
self.id = summary.id
|
||||
self.headline = summary.headline
|
||||
self.summary = summary.summary
|
||||
self.topic = summary.topic
|
||||
self.tags = summary.tags ?? []
|
||||
self.signalScore = summary.signalScore
|
||||
self.sourceCount = summary.sourceCount
|
||||
self.consensus = summary.consensus
|
||||
self.conflict = summary.conflict
|
||||
self.updatedAt = summary.updatedAt
|
||||
self.createdAt = summary.createdAt
|
||||
self.firstSeenAt = summary.firstSeenAt ?? summary.updatedAt
|
||||
self.cachedAt = Date()
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
final class ReadStory {
|
||||
@Attribute(.unique) var id: String
|
||||
var readAt: Date
|
||||
|
||||
init(id: String) {
|
||||
self.id = id
|
||||
self.readAt = Date()
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
final class SavedStory {
|
||||
@Attribute(.unique) var id: String
|
||||
var savedAt: Date
|
||||
|
||||
init(id: String) {
|
||||
self.id = id
|
||||
self.savedAt = Date()
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
final class SeenStory {
|
||||
@Attribute(.unique) var id: String
|
||||
var seenAt: Date
|
||||
|
||||
init(id: String) {
|
||||
self.id = id
|
||||
self.seenAt = Date()
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
final class CachedArticle {
|
||||
@Attribute(.unique) var id: String
|
||||
var storyId: String
|
||||
var source: String
|
||||
var headline: String
|
||||
var body: String
|
||||
var imageUrl: String?
|
||||
var author: String?
|
||||
var publishedAt: Date
|
||||
var cachedAt: Date
|
||||
|
||||
init(from article: Article) {
|
||||
self.id = article.id
|
||||
self.storyId = article.storyId
|
||||
self.source = article.source
|
||||
self.headline = article.headline
|
||||
self.body = article.body
|
||||
self.imageUrl = article.imageUrl
|
||||
self.author = article.author
|
||||
self.publishedAt = article.publishedAt
|
||||
self.cachedAt = Date()
|
||||
}
|
||||
}
|
||||
222
Jervis/Networking/APIClient.swift
Normal file
222
Jervis/Networking/APIClient.swift
Normal file
@@ -0,0 +1,222 @@
|
||||
// APIClient.swift
|
||||
// Jarvis — REST networking layer
|
||||
|
||||
import Foundation
|
||||
|
||||
enum APIError: Error, LocalizedError {
|
||||
case invalidURL
|
||||
case serverError(code: String, message: String, status: Int)
|
||||
case decodingError(Error)
|
||||
case networkError(Error)
|
||||
case notConnected
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "Invalid server URL"
|
||||
case .notConnected: return "Not connected to a Jarvis server"
|
||||
case .serverError(_, let msg, _): return msg
|
||||
case .decodingError(let e): return "Decode error: \(e.localizedDescription)"
|
||||
case .networkError(let e): return e.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// A benign URLSession cancellation (-999), e.g. a request superseded or
|
||||
/// dropped on a network-path change. Should not be shown or wipe state.
|
||||
var isCancelled: Bool {
|
||||
if case .networkError(let e) = self { return (e as? URLError)?.code == .cancelled }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private struct APIErrorResponse: Decodable {
|
||||
struct Inner: Decodable { let code, message: String; let status: Int }
|
||||
let error: Inner
|
||||
}
|
||||
|
||||
private struct EmptyResponse: Decodable {}
|
||||
|
||||
actor APIClient {
|
||||
static let shared = APIClient()
|
||||
|
||||
private var baseURL: URL?
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
private let deviceId: String
|
||||
|
||||
private init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 10
|
||||
self.session = URLSession(configuration: config)
|
||||
self.deviceId = APIClient.loadDeviceId()
|
||||
|
||||
self.decoder = JSONDecoder()
|
||||
self.decoder.dateDecodingStrategy = .iso8601
|
||||
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
}
|
||||
|
||||
private static func loadDeviceId() -> String {
|
||||
let key = "jarvis.device.id"
|
||||
if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
|
||||
return existing
|
||||
}
|
||||
let value = UUID().uuidString.lowercased()
|
||||
UserDefaults.standard.set(value, forKey: key)
|
||||
return value
|
||||
}
|
||||
|
||||
func configure(host: String) throws {
|
||||
guard let url = URL(string: "http://\(host)/api/v1") else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
self.baseURL = url
|
||||
}
|
||||
|
||||
// MARK: - Health
|
||||
|
||||
func checkHealth() async throws -> ServerHealth {
|
||||
try await get("/health")
|
||||
}
|
||||
|
||||
// MARK: - Stories
|
||||
|
||||
func fetchStories(
|
||||
cursor: String? = nil,
|
||||
topic: String? = nil,
|
||||
tags: Set<String> = [],
|
||||
minSignal: Int? = nil,
|
||||
limit: Int = 20,
|
||||
includeRead: Bool = false
|
||||
) async throws -> PaginatedStories {
|
||||
var params: [String: String] = ["limit": "\(limit)", "include_read": includeRead ? "true" : "false"]
|
||||
if let cursor { params["after"] = cursor }
|
||||
if let topic { params["topic"] = topic }
|
||||
if !tags.isEmpty { params["tags"] = tags.sorted().joined(separator: ",") }
|
||||
if let minSignal { params["min_signal"] = "\(minSignal)" }
|
||||
return try await get("/stories", params: params)
|
||||
}
|
||||
|
||||
func fetchStory(id: String) async throws -> StoryDetail {
|
||||
try await get("/stories/\(id)")
|
||||
}
|
||||
|
||||
func markStoryRead(id: String) async throws {
|
||||
try await postVoid("/stories/\(id)/read", body: [
|
||||
"device_id": deviceId,
|
||||
"read_at": ISO8601DateFormatter().string(from: Date()),
|
||||
"source": "ios",
|
||||
])
|
||||
}
|
||||
|
||||
func markStoryUnread(id: String) async throws {
|
||||
try await delete("/stories/\(id)/read")
|
||||
}
|
||||
|
||||
func markAllRead(storyIds: [String], pill: String) async throws {
|
||||
try await postVoid("/me/read/bulk", body: [
|
||||
"storyIds": storyIds,
|
||||
"readAt": ISO8601DateFormatter().string(from: Date()),
|
||||
"source": "mark_all_as_read",
|
||||
"pill": pill,
|
||||
"deviceId": deviceId,
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Articles
|
||||
|
||||
func fetchArticle(id: String) async throws -> Article {
|
||||
try await get("/articles/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Feeds
|
||||
|
||||
func fetchFeeds() async throws -> [Feed] {
|
||||
struct FeedsResponse: Decodable { let data: [Feed] }
|
||||
let response: FeedsResponse = try await get("/feeds")
|
||||
return response.data
|
||||
}
|
||||
|
||||
func addFeed(url: String, name: String, pollInterval: Int = 900) async throws -> Feed {
|
||||
let body = ["url": url, "name": name, "pollIntervalSeconds": pollInterval] as [String: Any]
|
||||
return try await post("/feeds", body: body)
|
||||
}
|
||||
|
||||
func deleteFeed(id: String) async throws {
|
||||
try await delete("/feeds/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private func get<T: Decodable>(_ path: String, params: [String: String] = [:]) async throws -> T {
|
||||
guard let base = baseURL else { throw APIError.notConnected }
|
||||
var components = URLComponents(url: base.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
|
||||
if !params.isEmpty {
|
||||
components.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) }
|
||||
}
|
||||
guard let url = components.url else { throw APIError.invalidURL }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
|
||||
return try await execute(request)
|
||||
}
|
||||
|
||||
private func post<T: Decodable>(_ path: String, body: [String: Any]) async throws -> T {
|
||||
guard let base = baseURL else { throw APIError.notConnected }
|
||||
let url = base.appendingPathComponent(path)
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
return try await execute(request)
|
||||
}
|
||||
|
||||
private func postVoid(_ path: String, body: [String: Any]) async throws {
|
||||
guard let base = baseURL else { throw APIError.notConnected }
|
||||
let url = base.appendingPathComponent(path)
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
let (_, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||
throw APIError.serverError(code: "post_failed", message: "Post failed", status: 500)
|
||||
}
|
||||
}
|
||||
|
||||
private func delete(_ path: String) async throws {
|
||||
guard let base = baseURL else { throw APIError.notConnected }
|
||||
let url = base.appendingPathComponent(path)
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "DELETE"
|
||||
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
|
||||
let (_, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 204 else {
|
||||
throw APIError.serverError(code: "delete_failed", message: "Delete failed", status: 500)
|
||||
}
|
||||
}
|
||||
|
||||
private func execute<T: Decodable>(_ request: URLRequest) async throws -> T {
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw APIError.networkError(URLError(.badServerResponse))
|
||||
}
|
||||
if !(200...299).contains(http.statusCode) {
|
||||
if let err = try? decoder.decode(APIErrorResponse.self, from: data) {
|
||||
throw APIError.serverError(code: err.error.code, message: err.error.message, status: err.error.status)
|
||||
}
|
||||
throw APIError.serverError(code: "unknown", message: "HTTP \(http.statusCode)", status: http.statusCode)
|
||||
}
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
} catch let error as APIError {
|
||||
throw error
|
||||
} catch {
|
||||
throw APIError.networkError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
135
Jervis/Networking/WebSocketManager.swift
Normal file
135
Jervis/Networking/WebSocketManager.swift
Normal file
@@ -0,0 +1,135 @@
|
||||
// WebSocketManager.swift
|
||||
// Jarvis — WebSocket connection, event parsing, reconnect with backoff
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
enum ConnectionState {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
case reconnecting(attempt: Int)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WebSocketManager: ObservableObject {
|
||||
static let shared = WebSocketManager()
|
||||
|
||||
@Published var connectionState: ConnectionState = .disconnected
|
||||
|
||||
let events = PassthroughSubject<WSEvent, Never>()
|
||||
|
||||
private var task: URLSessionWebSocketTask?
|
||||
private var pingTimer: Timer?
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
private var host: String?
|
||||
|
||||
private let maxBackoff: TimeInterval = 60
|
||||
private var attempt = 0
|
||||
|
||||
private init() {}
|
||||
|
||||
func connect(host: String) {
|
||||
self.host = host
|
||||
self.attempt = 0
|
||||
openConnection()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
reconnectTask?.cancel()
|
||||
pingTimer?.invalidate()
|
||||
task?.cancel(with: .goingAway, reason: nil)
|
||||
task = nil
|
||||
connectionState = .disconnected
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func openConnection() {
|
||||
guard let host, let url = URL(string: "ws://\(host)/ws") else { return }
|
||||
connectionState = attempt == 0 ? .connecting : .reconnecting(attempt: attempt)
|
||||
|
||||
let session = URLSession(configuration: .default)
|
||||
task = session.webSocketTask(with: url)
|
||||
task?.resume()
|
||||
|
||||
connectionState = .connected
|
||||
attempt = 0
|
||||
startPing()
|
||||
listen()
|
||||
}
|
||||
|
||||
private func listen() {
|
||||
// Capture task locally so the Sendable receive closure doesn't reference
|
||||
// the @MainActor-isolated property directly.
|
||||
guard let t = task else { return }
|
||||
t.receive { [weak self] result in
|
||||
switch result {
|
||||
case .failure:
|
||||
Task { @MainActor [weak self] in self?.handleDisconnect() }
|
||||
case .success(let message):
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.handle(message: message)
|
||||
self.listen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(message: URLSessionWebSocketTask.Message) {
|
||||
guard case .string(let text) = message,
|
||||
let data = text.data(using: .utf8) else { return }
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
|
||||
guard let event = try? decoder.decode(WSEvent.self, from: data) else { return }
|
||||
|
||||
if event.type == .ping {
|
||||
sendPong()
|
||||
return
|
||||
}
|
||||
|
||||
events.send(event)
|
||||
}
|
||||
|
||||
private func sendPong() {
|
||||
task?.send(.string(#"{"type":"pong"}"#)) { _ in }
|
||||
}
|
||||
|
||||
private func startPing() {
|
||||
pingTimer?.invalidate()
|
||||
pingTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
|
||||
// Hop to MainActor before touching the isolated `task` property.
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.task?.sendPing { [weak self] error in
|
||||
if error != nil {
|
||||
Task { @MainActor [weak self] in self?.handleDisconnect() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDisconnect() {
|
||||
pingTimer?.invalidate()
|
||||
task = nil
|
||||
connectionState = .disconnected
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
private func scheduleReconnect() {
|
||||
attempt += 1
|
||||
let delay = min(pow(2.0, Double(attempt - 1)), maxBackoff)
|
||||
connectionState = .reconnecting(attempt: attempt)
|
||||
|
||||
reconnectTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run { self?.openConnection() }
|
||||
}
|
||||
}
|
||||
}
|
||||
201
Jervis/Notifications/NotificationManager.swift
Normal file
201
Jervis/Notifications/NotificationManager.swift
Normal file
@@ -0,0 +1,201 @@
|
||||
// NotificationManager.swift
|
||||
// Jarvis — local notifications: morning/evening briefings (weather + top signal
|
||||
// stories) and breaking-story alerts. Refreshes briefing content on foreground and
|
||||
// via a background-refresh task.
|
||||
//
|
||||
// iOS limit: local notifications carry the content set at *schedule* time. We keep
|
||||
// them reasonably fresh by rebuilding on foreground + background refresh. Truly live
|
||||
// pushes while the app is closed would need a push server (APNs / ntfy) — see
|
||||
// docs/BACKLOG.md.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import UserNotifications
|
||||
import BackgroundTasks
|
||||
|
||||
enum BriefingPeriod { case morning, evening
|
||||
var greeting: String { self == .morning ? "Good morning" : "Good evening" }
|
||||
var requestID: String { self == .morning ? "briefing.morning" : "briefing.evening" }
|
||||
}
|
||||
|
||||
/// Background-refresh task identifier (also declared in Info.plist
|
||||
/// BGTaskSchedulerPermittedIdentifiers and registered in AppDelegate).
|
||||
let jarvisBriefingRefreshID = "com.kisani.jarvis.briefing.refresh"
|
||||
|
||||
@MainActor
|
||||
final class NotificationManager: NSObject, ObservableObject {
|
||||
static let shared = NotificationManager()
|
||||
|
||||
@Published var authStatus: UNAuthorizationStatus = .notDetermined
|
||||
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
private let settings = NotificationSettings.shared
|
||||
private let api = APIClient.shared
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private override init() { super.init() }
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func bootstrap() async {
|
||||
center.delegate = self
|
||||
await refreshAuthStatus()
|
||||
// Prompt for permission on first launch (.notDetermined).
|
||||
// Re-requesting after denial is a no-op (iOS ignores it) — safe to call every launch.
|
||||
if authStatus == .notDetermined {
|
||||
await requestAuthorization()
|
||||
}
|
||||
subscribeToBreakingEvents()
|
||||
await applySettings()
|
||||
}
|
||||
|
||||
// MARK: - Authorization
|
||||
|
||||
func refreshAuthStatus() async {
|
||||
authStatus = await center.notificationSettings().authorizationStatus
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func requestAuthorization() async -> Bool {
|
||||
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||
settings.enabled = granted
|
||||
await refreshAuthStatus()
|
||||
if granted { await applySettings() }
|
||||
return granted
|
||||
}
|
||||
|
||||
private var isAuthorized: Bool { authStatus == .authorized || authStatus == .provisional }
|
||||
|
||||
// MARK: - Apply settings (reschedule everything)
|
||||
|
||||
func applySettings() async {
|
||||
guard settings.enabled, isAuthorized else {
|
||||
center.removePendingNotificationRequests(withIdentifiers: [
|
||||
BriefingPeriod.morning.requestID, BriefingPeriod.evening.requestID])
|
||||
return
|
||||
}
|
||||
await resolveLocationIfNeeded()
|
||||
await scheduleBriefings()
|
||||
scheduleBackgroundRefresh()
|
||||
}
|
||||
|
||||
private func resolveLocationIfNeeded() async {
|
||||
guard settings.weatherEnabled, !settings.locationName.isEmpty,
|
||||
!settings.hasResolvedLocation else { return }
|
||||
if let geo = await WeatherService.geocode(settings.locationName) {
|
||||
settings.latitude = geo.latitude
|
||||
settings.longitude = geo.longitude
|
||||
settings.resolvedPlace = [geo.name, geo.country].compactMap { $0 }.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Briefings
|
||||
|
||||
func scheduleBriefings() async {
|
||||
center.removePendingNotificationRequests(withIdentifiers: [
|
||||
BriefingPeriod.morning.requestID, BriefingPeriod.evening.requestID])
|
||||
|
||||
if settings.morningEnabled {
|
||||
await schedule(.morning, hour: settings.morningHour, minute: settings.morningMinute)
|
||||
}
|
||||
if settings.eveningEnabled {
|
||||
await schedule(.evening, hour: settings.eveningHour, minute: settings.eveningMinute)
|
||||
}
|
||||
}
|
||||
|
||||
private func schedule(_ period: BriefingPeriod, hour: Int, minute: Int) async {
|
||||
let content = await buildBriefing(period)
|
||||
var comps = DateComponents(); comps.hour = hour; comps.minute = minute
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
|
||||
let req = UNNotificationRequest(identifier: period.requestID, content: content, trigger: trigger)
|
||||
try? await center.add(req)
|
||||
}
|
||||
|
||||
/// Build the "Jarvis" briefing: greeting + weather/rain + top stories to watch.
|
||||
private func buildBriefing(_ period: BriefingPeriod) async -> UNMutableNotificationContent {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = period.greeting
|
||||
content.sound = .default
|
||||
|
||||
var lines: [String] = []
|
||||
|
||||
if settings.weatherEnabled, let lat = settings.latitude, let lon = settings.longitude,
|
||||
let w = await WeatherService.forecast(latitude: lat, longitude: lon,
|
||||
place: settings.resolvedPlace ?? settings.locationName) {
|
||||
lines.append(w.summary)
|
||||
}
|
||||
|
||||
let top = await topStories(limit: 3)
|
||||
if !top.isEmpty {
|
||||
lines.append("Needs your attention:")
|
||||
for s in top { lines.append("• \(s.headline) (\(s.signalScore))") }
|
||||
} else if lines.isEmpty {
|
||||
lines.append("No new high-signal stories right now.")
|
||||
}
|
||||
|
||||
content.body = lines.joined(separator: "\n")
|
||||
if let first = top.first {
|
||||
content.subtitle = "Top signal · \(Topic.label(first.topic))"
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
private func topStories(limit: Int) async -> [StorySummary] {
|
||||
guard let page = try? await api.fetchStories(limit: 10) else { return [] }
|
||||
return Array(page.data.sorted { $0.signalScore > $1.signalScore }.prefix(limit))
|
||||
}
|
||||
|
||||
/// Fire a one-off briefing shortly (used by the "Send a test briefing" button).
|
||||
func sendTestBriefing() async {
|
||||
guard isAuthorized else { return }
|
||||
let content = await buildBriefing(.morning)
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3, repeats: false)
|
||||
try? await center.add(UNNotificationRequest(identifier: "briefing.test.\(UUID().uuidString)",
|
||||
content: content, trigger: trigger))
|
||||
}
|
||||
|
||||
// MARK: - Breaking alerts
|
||||
|
||||
private func subscribeToBreakingEvents() {
|
||||
WebSocketManager.shared.events
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] event in self?.handle(event) }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func handle(_ event: WSEvent) {
|
||||
guard settings.enabled, settings.breakingEnabled, isAuthorized,
|
||||
event.type == .storyCreated,
|
||||
let score = event.signalScore, score >= settings.breakingMinSignal,
|
||||
let headline = event.headline else { return }
|
||||
notifyBreaking(headline: headline, topic: event.topic, score: score)
|
||||
}
|
||||
|
||||
func notifyBreaking(headline: String, topic: String?, score: Int) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "🔴 Breaking" + (topic.map { " · \(Topic.label($0))" } ?? "")
|
||||
content.body = "\(headline) (signal \(score))"
|
||||
content.sound = .default
|
||||
let req = UNNotificationRequest(identifier: "breaking.\(UUID().uuidString)",
|
||||
content: content, trigger: nil)
|
||||
center.add(req)
|
||||
}
|
||||
|
||||
// MARK: - Background refresh
|
||||
|
||||
func scheduleBackgroundRefresh() {
|
||||
let request = BGAppRefreshTaskRequest(identifier: jarvisBriefingRefreshID)
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: 2 * 3600)
|
||||
try? BGTaskScheduler.shared.submit(request)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Foreground presentation
|
||||
|
||||
extension NotificationManager: UNUserNotificationCenterDelegate {
|
||||
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification) async
|
||||
-> UNNotificationPresentationOptions {
|
||||
[.banner, .sound, .list]
|
||||
}
|
||||
}
|
||||
75
Jervis/Notifications/NotificationSettings.swift
Normal file
75
Jervis/Notifications/NotificationSettings.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
// NotificationSettings.swift
|
||||
// Jarvis — persisted notification preferences: breaking alerts + morning/evening
|
||||
// briefings with weather.
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class NotificationSettings: ObservableObject {
|
||||
static let shared = NotificationSettings()
|
||||
private let key = "jarvis_notifications_v1"
|
||||
|
||||
// Master intent (actual OS permission tracked by NotificationManager).
|
||||
@Published var enabled: Bool { didSet { persist() } }
|
||||
|
||||
// Breaking / new-story alerts
|
||||
@Published var breakingEnabled: Bool { didSet { persist() } }
|
||||
@Published var breakingMinSignal: Int { didSet { persist() } }
|
||||
|
||||
// Morning briefing
|
||||
@Published var morningEnabled: Bool { didSet { persist() } }
|
||||
@Published var morningHour: Int { didSet { persist() } }
|
||||
@Published var morningMinute: Int { didSet { persist() } }
|
||||
|
||||
// Evening briefing
|
||||
@Published var eveningEnabled: Bool { didSet { persist() } }
|
||||
@Published var eveningHour: Int { didSet { persist() } }
|
||||
@Published var eveningMinute: Int { didSet { persist() } }
|
||||
|
||||
// Weather
|
||||
@Published var weatherEnabled: Bool { didSet { persist() } }
|
||||
@Published var locationName: String { didSet { persist() } }
|
||||
@Published var latitude: Double? { didSet { persist() } }
|
||||
@Published var longitude: Double? { didSet { persist() } }
|
||||
@Published var resolvedPlace: String? { didSet { persist() } }
|
||||
|
||||
private struct Stored: Codable {
|
||||
var enabled, breakingEnabled, morningEnabled, eveningEnabled, weatherEnabled: Bool
|
||||
var breakingMinSignal, morningHour, morningMinute, eveningHour, eveningMinute: Int
|
||||
var locationName: String
|
||||
var latitude, longitude: Double?
|
||||
var resolvedPlace: String?
|
||||
}
|
||||
|
||||
private init() {
|
||||
if let data = UserDefaults.standard.data(forKey: key),
|
||||
let s = try? JSONDecoder().decode(Stored.self, from: data) {
|
||||
enabled = s.enabled
|
||||
breakingEnabled = s.breakingEnabled; breakingMinSignal = s.breakingMinSignal
|
||||
morningEnabled = s.morningEnabled; morningHour = s.morningHour; morningMinute = s.morningMinute
|
||||
eveningEnabled = s.eveningEnabled; eveningHour = s.eveningHour; eveningMinute = s.eveningMinute
|
||||
weatherEnabled = s.weatherEnabled; locationName = s.locationName
|
||||
latitude = s.latitude; longitude = s.longitude; resolvedPlace = s.resolvedPlace
|
||||
} else {
|
||||
enabled = true
|
||||
breakingEnabled = true; breakingMinSignal = 65
|
||||
morningEnabled = true; morningHour = 7; morningMinute = 0
|
||||
eveningEnabled = true; eveningHour = 18; eveningMinute = 0
|
||||
weatherEnabled = true; locationName = ""
|
||||
latitude = nil; longitude = nil; resolvedPlace = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
let s = Stored(enabled: enabled, breakingEnabled: breakingEnabled,
|
||||
morningEnabled: morningEnabled, eveningEnabled: eveningEnabled,
|
||||
weatherEnabled: weatherEnabled, breakingMinSignal: breakingMinSignal,
|
||||
morningHour: morningHour, morningMinute: morningMinute,
|
||||
eveningHour: eveningHour, eveningMinute: eveningMinute,
|
||||
locationName: locationName, latitude: latitude, longitude: longitude,
|
||||
resolvedPlace: resolvedPlace)
|
||||
if let data = try? JSONEncoder().encode(s) { UserDefaults.standard.set(data, forKey: key) }
|
||||
}
|
||||
|
||||
var hasResolvedLocation: Bool { latitude != nil && longitude != nil }
|
||||
}
|
||||
93
Jervis/Notifications/WeatherService.swift
Normal file
93
Jervis/Notifications/WeatherService.swift
Normal file
@@ -0,0 +1,93 @@
|
||||
// WeatherService.swift
|
||||
// Jarvis — weather via Open-Meteo (free, no API key, no entitlement). Used to put
|
||||
// a "will it rain today" line in the morning/evening briefing.
|
||||
|
||||
import Foundation
|
||||
|
||||
struct DailyWeather {
|
||||
let place: String
|
||||
let tempMax: Double
|
||||
let tempMin: Double
|
||||
let precipProbability: Int // %
|
||||
let code: Int // WMO weather code
|
||||
|
||||
var willRain: Bool { precipProbability >= 40 }
|
||||
|
||||
/// e.g. "Kampala 26°/18°, light rain · 70% — take an umbrella ☔️"
|
||||
var summary: String {
|
||||
let temps = "\(Int(tempMax.rounded()))°/\(Int(tempMin.rounded()))°"
|
||||
let cond = WeatherService.codeText(code)
|
||||
let rain = "\(precipProbability)% rain"
|
||||
let tail = willRain ? " — take an umbrella ☔️" : ""
|
||||
return "\(place) \(temps), \(cond) · \(rain)\(tail)"
|
||||
}
|
||||
}
|
||||
|
||||
enum WeatherService {
|
||||
struct GeoResult: Decodable { let latitude: Double; let longitude: Double; let name: String; let country: String? }
|
||||
private struct GeoResponse: Decodable { let results: [GeoResult]? }
|
||||
private struct ForecastResponse: Decodable {
|
||||
struct Daily: Decodable {
|
||||
let temperature_2m_max: [Double]
|
||||
let temperature_2m_min: [Double]
|
||||
let precipitation_probability_max: [Int?]
|
||||
let weathercode: [Int]
|
||||
}
|
||||
let daily: Daily
|
||||
}
|
||||
|
||||
private static let session = URLSession(configuration: .default)
|
||||
|
||||
/// Geocode a free-text place to coordinates.
|
||||
static func geocode(_ query: String) async -> GeoResult? {
|
||||
let q = query.trimmingCharacters(in: .whitespaces)
|
||||
guard !q.isEmpty,
|
||||
var c = URLComponents(string: "https://geocoding-api.open-meteo.com/v1/search")
|
||||
else { return nil }
|
||||
c.queryItems = [.init(name: "name", value: q), .init(name: "count", value: "1"),
|
||||
.init(name: "language", value: "en"), .init(name: "format", value: "json")]
|
||||
guard let url = c.url else { return nil }
|
||||
do {
|
||||
let (data, _) = try await session.data(from: url)
|
||||
return try JSONDecoder().decode(GeoResponse.self, from: data).results?.first
|
||||
} catch { return nil }
|
||||
}
|
||||
|
||||
/// Today's forecast for coordinates.
|
||||
static func forecast(latitude: Double, longitude: Double, place: String) async -> DailyWeather? {
|
||||
guard var c = URLComponents(string: "https://api.open-meteo.com/v1/forecast") else { return nil }
|
||||
c.queryItems = [
|
||||
.init(name: "latitude", value: "\(latitude)"),
|
||||
.init(name: "longitude", value: "\(longitude)"),
|
||||
.init(name: "daily", value: "temperature_2m_max,temperature_2m_min,precipitation_probability_max,weathercode"),
|
||||
.init(name: "timezone", value: "auto"),
|
||||
.init(name: "forecast_days", value: "1"),
|
||||
]
|
||||
guard let url = c.url else { return nil }
|
||||
do {
|
||||
let (data, _) = try await session.data(from: url)
|
||||
let r = try JSONDecoder().decode(ForecastResponse.self, from: data)
|
||||
guard let tmax = r.daily.temperature_2m_max.first,
|
||||
let tmin = r.daily.temperature_2m_min.first,
|
||||
let code = r.daily.weathercode.first else { return nil }
|
||||
let prob = r.daily.precipitation_probability_max.first.flatMap { $0 } ?? 0
|
||||
return DailyWeather(place: place, tempMax: tmax, tempMin: tmin, precipProbability: prob, code: code)
|
||||
} catch { return nil }
|
||||
}
|
||||
|
||||
/// Coarse WMO weather-code → words.
|
||||
static func codeText(_ code: Int) -> String {
|
||||
switch code {
|
||||
case 0: return "clear"
|
||||
case 1, 2: return "partly cloudy"
|
||||
case 3: return "overcast"
|
||||
case 45, 48: return "fog"
|
||||
case 51, 53, 55, 56, 57: return "drizzle"
|
||||
case 61, 63, 65, 66, 67: return "rain"
|
||||
case 71, 73, 75, 77: return "snow"
|
||||
case 80, 81, 82: return "rain showers"
|
||||
case 95, 96, 99: return "thunderstorms"
|
||||
default: return "mixed"
|
||||
}
|
||||
}
|
||||
}
|
||||
151
Jervis/Store/BackgroundRefreshManager.swift
Normal file
151
Jervis/Store/BackgroundRefreshManager.swift
Normal file
@@ -0,0 +1,151 @@
|
||||
// BackgroundRefreshManager.swift
|
||||
// Jarvis — background story cache refresh. Fires via BGAppRefreshTask every ~30 min,
|
||||
// fetches the latest feed, and upserts CachedStory rows so the SwiftData cache is
|
||||
// warm before the user opens the app.
|
||||
|
||||
import Foundation
|
||||
import BackgroundTasks
|
||||
import SwiftData
|
||||
import UserNotifications
|
||||
|
||||
let jarvisFeedRefreshID = "com.kisani.jarvis.feed.refresh"
|
||||
|
||||
enum BackgroundRefreshManager {
|
||||
|
||||
// Injected once from JarvisApp so the BG task can open a context on the same store.
|
||||
static var container: ModelContainer?
|
||||
|
||||
// MARK: - Task handler
|
||||
|
||||
static func handleFeedRefresh(task: BGAppRefreshTask) {
|
||||
// Reschedule first — if the task is killed we still get another run later.
|
||||
scheduleFeedRefresh()
|
||||
|
||||
guard let container else {
|
||||
task.setTaskCompleted(success: false)
|
||||
return
|
||||
}
|
||||
|
||||
let work = Task {
|
||||
await fetchAndCache(container: container)
|
||||
}
|
||||
|
||||
task.expirationHandler = {
|
||||
work.cancel()
|
||||
task.setTaskCompleted(success: false)
|
||||
}
|
||||
|
||||
Task {
|
||||
_ = await work.result
|
||||
task.setTaskCompleted(success: !work.isCancelled)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Core fetch + upsert
|
||||
|
||||
private static func fetchAndCache(container: ModelContainer) async {
|
||||
do {
|
||||
let page = try await APIClient.shared.fetchStories(limit: 40)
|
||||
let context = ModelContext(container)
|
||||
|
||||
var newStories: [StorySummary] = []
|
||||
|
||||
for story in page.data {
|
||||
let id = story.id
|
||||
if let existing = (try? context.fetch(
|
||||
FetchDescriptor<CachedStory>(predicate: #Predicate { $0.id == id })))?.first {
|
||||
existing.signalScore = story.signalScore
|
||||
existing.sourceCount = story.sourceCount
|
||||
existing.updatedAt = story.updatedAt
|
||||
} else {
|
||||
context.insert(CachedStory(from: story))
|
||||
newStories.append(story)
|
||||
}
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
|
||||
// Pre-cache articles for offline reading
|
||||
await preCacheArticles(page.data, container: container)
|
||||
|
||||
if !newStories.isEmpty {
|
||||
notifyNewStories(newStories)
|
||||
}
|
||||
} catch {
|
||||
// Network unavailable in background — fail silently, next run will catch up.
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Article pre-caching
|
||||
|
||||
/// Fetch and store the top article for each high-signal uncached story so
|
||||
/// the user can read them offline without having opened them first.
|
||||
/// Safe to call in background or foreground — creates its own ModelContext.
|
||||
static func preCacheArticles(_ stories: [StorySummary], container: ModelContainer) async {
|
||||
let context = ModelContext(container)
|
||||
let top = stories
|
||||
.filter { $0.signalScore >= 60 && !$0.sources.isEmpty }
|
||||
.sorted { $0.signalScore > $1.signalScore }
|
||||
.prefix(20)
|
||||
|
||||
for story in top {
|
||||
let sid = story.id
|
||||
let alreadyCached = (try? context.fetch(
|
||||
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.storyId == sid })
|
||||
))?.isEmpty == false
|
||||
guard !alreadyCached, let src = story.sources.first else { continue }
|
||||
guard let art = try? await APIClient.shared.fetchArticle(id: src.id) else { continue }
|
||||
context.insert(CachedArticle(from: art))
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - New-story notifications
|
||||
|
||||
// Slugs for the featured categories the user cares about.
|
||||
private static let featuredSlugs: Set<String> = [
|
||||
"artificial-intelligence", "machine-learning", "robotics",
|
||||
"technology", "cybersecurity", "security", "privacy-and-data-protection",
|
||||
"programming-and-software-development", "cloud-computing", "web-design-and-ui-ux",
|
||||
"homelab",
|
||||
]
|
||||
|
||||
private static let minSignal = 65
|
||||
|
||||
private static func notifyNewStories(_ stories: [StorySummary]) {
|
||||
// Best overall story above threshold.
|
||||
let eligible = stories
|
||||
.filter { $0.signalScore >= minSignal }
|
||||
.sorted { $0.signalScore > $1.signalScore }
|
||||
guard let lead = eligible.first else { return }
|
||||
|
||||
let tags = Set((lead.tags ?? []).isEmpty ? [lead.topic] : (lead.tags ?? []))
|
||||
let isFeatured = !tags.isDisjoint(with: featuredSlugs)
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = isFeatured
|
||||
? "📡 \(Topic.label(lead.topic))"
|
||||
: "📡 New signals"
|
||||
content.body = lead.headline
|
||||
if eligible.count > 1 {
|
||||
content.subtitle = "+\(eligible.count - 1) more new \(isFeatured ? "stories" : "high-signal stories")"
|
||||
}
|
||||
content.sound = .default
|
||||
|
||||
let req = UNNotificationRequest(
|
||||
identifier: "feed.new.\(UUID().uuidString)",
|
||||
content: content,
|
||||
trigger: nil
|
||||
)
|
||||
UNUserNotificationCenter.current().add(req)
|
||||
}
|
||||
|
||||
// MARK: - Schedule
|
||||
|
||||
static func scheduleFeedRefresh() {
|
||||
let req = BGAppRefreshTaskRequest(identifier: jarvisFeedRefreshID)
|
||||
// iOS enforces a minimum interval (~15 min); 30 min is a reasonable target.
|
||||
req.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60)
|
||||
try? BGTaskScheduler.shared.submit(req)
|
||||
}
|
||||
}
|
||||
60
Jervis/Store/CacheMaintenance.swift
Normal file
60
Jervis/Store/CacheMaintenance.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
// CacheMaintenance.swift
|
||||
// Jarvis — bounds the on-device SwiftData caches so they don't grow forever.
|
||||
// Saved (bookmarked) stories are always protected.
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
enum CacheMaintenance {
|
||||
private static let hour: TimeInterval = 3_600
|
||||
private static let day: TimeInterval = 86_400
|
||||
|
||||
private static let recentHours = 72.0 // 3 days: mirrors backend active story window
|
||||
private static let markerHours = 192.0 // 8 days: read suppression survives weekly repeats
|
||||
private static let articleDays = 14.0 // cached article bodies (backend article retention)
|
||||
|
||||
/// Age-based eviction. Run on launch. Cheap; protects saved stories.
|
||||
static func prune(_ context: ModelContext) {
|
||||
let now = Date()
|
||||
let seenCutoff = now.addingTimeInterval(-markerHours * hour)
|
||||
let readCutoff = now.addingTimeInterval(-markerHours * hour)
|
||||
let articleCutoff = now.addingTimeInterval(-articleDays * day)
|
||||
let storyCutoff = now.addingTimeInterval(-recentHours * hour)
|
||||
|
||||
// Markers: simple age batch deletes.
|
||||
try? context.delete(model: SeenStory.self, where: #Predicate { $0.seenAt < seenCutoff })
|
||||
try? context.delete(model: ReadStory.self, where: #Predicate { $0.readAt < readCutoff })
|
||||
|
||||
// Offline content: drop old entries that aren't saved.
|
||||
let saved = savedIDs(context)
|
||||
if let arts = try? context.fetch(
|
||||
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.cachedAt < articleCutoff })) {
|
||||
for a in arts where !saved.contains(a.storyId) { context.delete(a) }
|
||||
}
|
||||
if let stories = try? context.fetch(
|
||||
FetchDescriptor<CachedStory>(predicate: #Predicate { $0.cachedAt < storyCutoff })) {
|
||||
for s in stories where !saved.contains(s.id) { context.delete(s) }
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
/// Manual "Clear cache" — wipes offline stories/articles + seen/read state,
|
||||
/// keeps your saved bookmarks (and their articles).
|
||||
static func clear(_ context: ModelContext) {
|
||||
try? context.delete(model: SeenStory.self)
|
||||
try? context.delete(model: ReadStory.self)
|
||||
let saved = savedIDs(context)
|
||||
if let arts = try? context.fetch(FetchDescriptor<CachedArticle>()) {
|
||||
for a in arts where !saved.contains(a.storyId) { context.delete(a) }
|
||||
}
|
||||
if let stories = try? context.fetch(FetchDescriptor<CachedStory>()) {
|
||||
for s in stories where !saved.contains(s.id) { context.delete(s) }
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
private static func savedIDs(_ context: ModelContext) -> Set<String> {
|
||||
Set((try? context.fetch(FetchDescriptor<SavedStory>()))?.map(\.id) ?? [])
|
||||
}
|
||||
}
|
||||
48
Jervis/Store/ServerSettings.swift
Normal file
48
Jervis/Store/ServerSettings.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
// ServerSettings.swift
|
||||
// Jarvis — persists the configured server host
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ServerSettings: ObservableObject {
|
||||
static let shared = ServerSettings()
|
||||
|
||||
private let key = "jarvis_server_host"
|
||||
|
||||
@Published var host: String? {
|
||||
didSet { UserDefaults.standard.set(host, forKey: key) }
|
||||
}
|
||||
|
||||
var isConfigured: Bool { host != nil }
|
||||
|
||||
private init() {
|
||||
host = UserDefaults.standard.string(forKey: key)
|
||||
}
|
||||
|
||||
func save(host: String) async throws {
|
||||
try await APIClient.shared.configure(host: host)
|
||||
_ = try await APIClient.shared.checkHealth()
|
||||
self.host = host
|
||||
WebSocketManager.shared.connect(host: host)
|
||||
}
|
||||
|
||||
func reset() {
|
||||
host = nil
|
||||
WebSocketManager.shared.disconnect()
|
||||
}
|
||||
|
||||
/// Re-establish REST + WebSocket from a previously saved host on app launch.
|
||||
func reconnectIfConfigured() async {
|
||||
guard let host else { return }
|
||||
try? await APIClient.shared.configure(host: host)
|
||||
WebSocketManager.shared.connect(host: host)
|
||||
}
|
||||
|
||||
/// Point REST + WebSocket at a host the ConnectivityManager already verified
|
||||
/// (skips the health check — the probe just confirmed it).
|
||||
func activate(host: String) async {
|
||||
try? await APIClient.shared.configure(host: host)
|
||||
self.host = host
|
||||
WebSocketManager.shared.connect(host: host)
|
||||
}
|
||||
}
|
||||
334
Jervis/Store/StoryStore.swift
Normal file
334
Jervis/Store/StoryStore.swift
Normal file
@@ -0,0 +1,334 @@
|
||||
// StoryStore.swift
|
||||
// Jarvis — central observable store. Views read from here, never hit the API directly.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
final class StoryStore: ObservableObject {
|
||||
static let shared = StoryStore()
|
||||
|
||||
@Published var stories: [StorySummary] = []
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var error: APIError?
|
||||
@Published var lastSyncedAt: Date?
|
||||
@Published var hasMore = false
|
||||
@Published var selectedTopic: String? = nil
|
||||
@Published var selectedTags: Set<String> = []
|
||||
@Published var sectionSupplement: [String: [StorySummary]] = [:]
|
||||
|
||||
private var nextCursor: String?
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
private var foregroundSyncTask: Task<Void, Never>?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Quick cache (UserDefaults — synchronous, no async gap)
|
||||
|
||||
private let quickCacheKey = "jarvis.quickStories.v1"
|
||||
private let quickCacheMaxAge: TimeInterval = 84 * 3600 // 3.5 days
|
||||
|
||||
/// Restore the last-known feed from UserDefaults synchronously on init.
|
||||
/// This runs before the first SwiftUI render so the feed is never blank.
|
||||
private func restoreQuickCache() {
|
||||
guard let raw = UserDefaults.standard.data(forKey: quickCacheKey) else { return }
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let payload = try? decoder.decode(QuickCachePayload.self, from: raw) else { return }
|
||||
// Discard if too stale — matches CacheMaintenance window.
|
||||
guard Date().timeIntervalSince(payload.savedAt) < quickCacheMaxAge else {
|
||||
UserDefaults.standard.removeObject(forKey: quickCacheKey)
|
||||
return
|
||||
}
|
||||
if !payload.stories.isEmpty { stories = payload.stories }
|
||||
}
|
||||
|
||||
/// Persist the current feed to UserDefaults so it survives process kills.
|
||||
/// Encoding runs off the main thread; UserDefaults.set is thread-safe.
|
||||
private func persistQuickCache() {
|
||||
let payload = QuickCachePayload(stories: Array(stories.prefix(100)), savedAt: Date())
|
||||
let key = quickCacheKey
|
||||
Task.detached {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
if let data = try? encoder.encode(payload) {
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct QuickCachePayload: Codable {
|
||||
let stories: [StorySummary]
|
||||
let savedAt: Date
|
||||
}
|
||||
|
||||
// MARK: - SwiftData bootstrap (secondary, async — covers first-ever launch before
|
||||
// the quick cache exists, e.g. app reinstall).
|
||||
func bootstrap(container: ModelContainer) {
|
||||
guard stories.isEmpty else { return }
|
||||
let ctx = ModelContext(container)
|
||||
if let cached = try? ctx.fetch(FetchDescriptor<CachedStory>()) {
|
||||
let sorted = cached.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
|
||||
if !sorted.isEmpty { stories = sorted }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Foreground sync
|
||||
|
||||
private var isFetchingFull = false
|
||||
|
||||
/// Load page 1 then paginate to ≤100 stories.
|
||||
/// Guarded against concurrent invocations and rate-limited to ≥30 s between
|
||||
/// fetches so rapid foreground/background cycles don't hammer the API.
|
||||
func loadFeedFull() async {
|
||||
guard !isFetchingFull else { return }
|
||||
// Rate-limit: if the last *successful* sync was < 30 s ago, skip.
|
||||
if let last = lastSyncedAt, Date().timeIntervalSince(last) < 30 { return }
|
||||
isFetchingFull = true
|
||||
defer { isFetchingFull = false }
|
||||
|
||||
await loadStories(refresh: true)
|
||||
var pages = 0
|
||||
while !Task.isCancelled, hasMore, stories.count < 100, pages < 4 {
|
||||
await loadMore()
|
||||
pages += 1
|
||||
}
|
||||
loadSectionSupplements()
|
||||
}
|
||||
|
||||
/// Call when app becomes active. Polls every 90 s so new stories surface fast.
|
||||
func startForegroundSync() {
|
||||
foregroundSyncTask?.cancel()
|
||||
foregroundSyncTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 90 * 1_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.loadFeedFull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopForegroundSync() {
|
||||
foregroundSyncTask?.cancel()
|
||||
foregroundSyncTask = nil
|
||||
}
|
||||
private let ws = WebSocketManager.shared
|
||||
private let api = APIClient.shared
|
||||
|
||||
private init() {
|
||||
restoreQuickCache() // synchronous — stories are ready before first render
|
||||
subscribeToWebSocket()
|
||||
}
|
||||
|
||||
// MARK: - Load
|
||||
|
||||
@discardableResult
|
||||
func loadStories(refresh: Bool = false) async -> Int {
|
||||
guard !isLoading else { return 0 }
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
let cursor = refresh ? nil : nextCursor
|
||||
var newCount = 0
|
||||
|
||||
do {
|
||||
let result = try await api.fetchStories(
|
||||
cursor: cursor,
|
||||
topic: selectedTopic,
|
||||
tags: selectedTags
|
||||
)
|
||||
if refresh {
|
||||
// Replace list; count only genuinely new IDs for reshuffle logic.
|
||||
let existingIds = Set(stories.map(\.id))
|
||||
newCount = result.data.filter { !existingIds.contains($0.id) }.count
|
||||
stories = result.data
|
||||
} else {
|
||||
// Append only IDs not already present — cursor can overlap.
|
||||
let existingIds = Set(stories.map(\.id))
|
||||
let novel = result.data.filter { !existingIds.contains($0.id) }
|
||||
stories += novel
|
||||
newCount = novel.count
|
||||
}
|
||||
nextCursor = result.nextCursor
|
||||
hasMore = result.hasMore
|
||||
lastSyncedAt = Date()
|
||||
persistQuickCache() // survives the next process kill
|
||||
preCacheArticlesInBackground()
|
||||
} catch let e as APIError where !e.isCancelled {
|
||||
error = e
|
||||
print("[StoryStore] fetch failed: \(e.localizedDescription)")
|
||||
} catch {
|
||||
print("[StoryStore] unexpected fetch error: \(error)")
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
return newCount
|
||||
}
|
||||
|
||||
private func preCacheArticlesInBackground() {
|
||||
guard let container = BackgroundRefreshManager.container else { return }
|
||||
let snapshot = stories
|
||||
Task.detached { await BackgroundRefreshManager.preCacheArticles(snapshot, container: container) }
|
||||
}
|
||||
|
||||
/// Merge cached stories into the live feed (used when pull-refresh finds nothing new).
|
||||
func mergeStories(_ extra: [StorySummary]) async {
|
||||
let existing = Set(stories.map(\.id))
|
||||
let novel = extra.filter { !existing.contains($0.id) }
|
||||
guard !novel.isEmpty else { return }
|
||||
stories = (stories + novel).sorted(by: StorySummary.feedOrder)
|
||||
}
|
||||
|
||||
func loadMore() async {
|
||||
guard hasMore, !isLoadingMore, let cursor = nextCursor else { return }
|
||||
isLoadingMore = true
|
||||
do {
|
||||
let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic, tags: selectedTags)
|
||||
// Deduplicate: cursor-based pages can overlap when stories are added mid-fetch.
|
||||
let existingIds = Set(stories.map(\.id))
|
||||
let novel = result.data.filter { !existingIds.contains($0.id) }
|
||||
stories += novel
|
||||
nextCursor = result.nextCursor
|
||||
hasMore = result.hasMore
|
||||
} catch {
|
||||
print("[StoryStore] loadMore error: \(error)")
|
||||
}
|
||||
isLoadingMore = false
|
||||
}
|
||||
|
||||
/// Coalesce bursts of `story.created` WS events — waits 3 s then does a
|
||||
/// full paginated load so stories on page 2+ are captured.
|
||||
private func scheduleCoalescedRefresh() {
|
||||
refreshTask?.cancel()
|
||||
refreshTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.loadFeedFull()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Section supplements
|
||||
// Sections with regional or niche tags rarely rank in the global top-100,
|
||||
// so we fetch a small targeted pool for each sparse section and let
|
||||
// makeDigest() use it as a fallback.
|
||||
|
||||
private let supplementSections: [(id: String, tags: Set<String>)] = [
|
||||
("east-africa", ["east-africa"]),
|
||||
("africa", ["africa"]),
|
||||
("sport", ["sports", "esports", "formula-1"]),
|
||||
("science", ["science", "astronomy"]),
|
||||
("health", ["health", "health-and-wellness"]),
|
||||
]
|
||||
|
||||
func loadSectionSupplements() {
|
||||
guard selectedTags.isEmpty else { return } // only for "All" pill
|
||||
let sections = supplementSections
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
var merged: [String: [StorySummary]] = [:]
|
||||
await withTaskGroup(of: (String, [StorySummary]?).self) { group in
|
||||
for sec in sections {
|
||||
let id = sec.id
|
||||
let tags = sec.tags
|
||||
group.addTask {
|
||||
let result = try? await APIClient.shared.fetchStories(tags: tags, limit: 8)
|
||||
return (id, result?.data)
|
||||
}
|
||||
}
|
||||
for await (id, data) in group {
|
||||
if let data { merged[id] = data }
|
||||
}
|
||||
}
|
||||
self.sectionSupplement = merged // single main-thread update
|
||||
}
|
||||
}
|
||||
|
||||
func setTopic(_ topic: String?) async {
|
||||
selectedTopic = topic
|
||||
selectedTags = []
|
||||
await loadFeedFull()
|
||||
}
|
||||
|
||||
func setPill(_ pill: StoryPill) async {
|
||||
selectedTopic = nil
|
||||
selectedTags = pill.slugs
|
||||
nextCursor = nil
|
||||
// Filter changed — must fetch regardless of rate-limit, otherwise
|
||||
// the old stories stay in place and the new pill finds almost nothing.
|
||||
lastSyncedAt = nil
|
||||
await loadFeedFull()
|
||||
}
|
||||
|
||||
// MARK: - WebSocket
|
||||
|
||||
private func subscribeToWebSocket() {
|
||||
ws.events
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] event in
|
||||
self?.handle(event: event)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func handle(event: WSEvent) {
|
||||
switch event.type {
|
||||
|
||||
case .storyUpdated:
|
||||
guard let id = event.storyId,
|
||||
let score = event.signalScore,
|
||||
let count = event.sourceCount else { return }
|
||||
updateStory(id: id, signalScore: score, sourceCount: count)
|
||||
Task { await refetch(storyId: id) }
|
||||
|
||||
case .storyCreated:
|
||||
scheduleCoalescedRefresh()
|
||||
|
||||
case .storyStale:
|
||||
guard let id = event.storyId else { return }
|
||||
removeStory(id: id)
|
||||
|
||||
case .feedHealth:
|
||||
NotificationCenter.default.post(name: .feedHealthChanged, object: event)
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func updateStory(id: String, signalScore: Int, sourceCount: Int) {
|
||||
guard let idx = stories.firstIndex(where: { $0.id == id }) else { return }
|
||||
let old = stories[idx]
|
||||
let updated = StorySummary(
|
||||
id: old.id,
|
||||
headline: old.headline,
|
||||
summary: old.summary,
|
||||
topic: old.topic,
|
||||
tags: old.tags,
|
||||
signalScore: signalScore,
|
||||
scoreBreakdown: old.scoreBreakdown,
|
||||
sourceCount: sourceCount,
|
||||
sources: old.sources,
|
||||
consensus: old.consensus,
|
||||
conflict: old.conflict,
|
||||
updatedAt: old.updatedAt,
|
||||
createdAt: old.createdAt,
|
||||
firstSeenAt: old.firstSeenAt
|
||||
)
|
||||
stories[idx] = updated
|
||||
stories.sort(by: StorySummary.feedOrder)
|
||||
}
|
||||
|
||||
private func removeStory(id: String) {
|
||||
stories.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
private func refetch(storyId: String) async {
|
||||
// Lightweight: only update what changed via WS patch above.
|
||||
// Full detail is fetched lazily when user taps into the story.
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let feedHealthChanged = Notification.Name("feedHealthChanged")
|
||||
}
|
||||
321
Jervis/Views/Connectivity/ConnectivityView.swift
Normal file
321
Jervis/Views/Connectivity/ConnectivityView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
113
Jervis/Views/Feeds/AddFeedSheet.swift
Normal file
113
Jervis/Views/Feeds/AddFeedSheet.swift
Normal 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 = "Couldn’t add feed. Check the URL and try again." }
|
||||
}
|
||||
}
|
||||
328
Jervis/Views/Feeds/FeedManagerView.swift
Normal file
328
Jervis/Views/Feeds/FeedManagerView.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
793
Jervis/Views/Home/SignalFeedView.swift
Normal file
793
Jervis/Views/Home/SignalFeedView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
131
Jervis/Views/Home/StoryRowView.swift
Normal file
131
Jervis/Views/Home/StoryRowView.swift
Normal 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)
|
||||
}
|
||||
128
Jervis/Views/Onboarding/OnboardingView.swift
Normal file
128
Jervis/Views/Onboarding/OnboardingView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
493
Jervis/Views/Reader/ArticleReaderView.swift
Normal file
493
Jervis/Views/Reader/ArticleReaderView.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
33
Jervis/Views/RootTabView.swift
Normal file
33
Jervis/Views/RootTabView.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
192
Jervis/Views/SecondaryTabs.swift
Normal file
192
Jervis/Views/SecondaryTabs.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
220
Jervis/Views/Settings/NotificationsView.swift
Normal file
220
Jervis/Views/Settings/NotificationsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
147
Jervis/Views/Settings/SettingsView.swift
Normal file
147
Jervis/Views/Settings/SettingsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
43
Jervis/Views/Shared/CachedBadge.swift
Normal file
43
Jervis/Views/Shared/CachedBadge.swift
Normal 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)
|
||||
}
|
||||
60
Jervis/Views/Shared/ConnectionBanner.swift
Normal file
60
Jervis/Views/Shared/ConnectionBanner.swift
Normal 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)
|
||||
}
|
||||
15
Jervis/Views/Shared/ShareSheet.swift
Normal file
15
Jervis/Views/Shared/ShareSheet.swift
Normal 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) {}
|
||||
}
|
||||
33
Jervis/Views/Shared/SignalStripe.swift
Normal file
33
Jervis/Views/Shared/SignalStripe.swift
Normal 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)
|
||||
}
|
||||
42
Jervis/Views/Shared/SourceChips.swift
Normal file
42
Jervis/Views/Shared/SourceChips.swift
Normal 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)
|
||||
}
|
||||
441
Jervis/Views/Shared/Theme.swift
Normal file
441
Jervis/Views/Shared/Theme.swift
Normal 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, 0–19 near invisible
|
||||
// score : 80–100 full orange · 50–79 dimmed orange · 20–49 dark brown · 0–19 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),
|
||||
]
|
||||
}
|
||||
320
Jervis/Views/Story/StoryDetailView.swift
Normal file
320
Jervis/Views/Story/StoryDetailView.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user