Initial commit: Jarvis iOS app
SwiftUI client for a self-hosted RSS news-correlation platform: signal feed, story detail, article reader, feed manager, and LAN⇄Tailscale connectivity. Project generated from project.yml via XcodeGen. Includes CI build matrix (macOS 14/15 × Debug/Release), issue templates, backlog, and API/backend handoff docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
43
Jarvis/Views/Shared/CachedBadge.swift
Normal file
43
Jarvis/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(Color.black)
|
||||
}
|
||||
60
Jarvis/Views/Shared/ConnectionBanner.swift
Normal file
60
Jarvis/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 Color(hex: "555555")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(Color.black)
|
||||
}
|
||||
31
Jarvis/Views/Shared/SignalStripe.swift
Normal file
31
Jarvis/Views/Shared/SignalStripe.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
// 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
|
||||
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(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(Color.black)
|
||||
}
|
||||
42
Jarvis/Views/Shared/SourceChips.swift
Normal file
42
Jarvis/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: 12, weight: .bold))
|
||||
.foregroundStyle(muted ? Color(hex: "777777") : Color(hex: "CCCCCC"))
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 5)
|
||||
.background(Palette.surface2)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SourceChips(names: ["Daily Monitor", "NilePost", "The EastAfrican"], total: 7)
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
}
|
||||
162
Jarvis/Views/Shared/Theme.swift
Normal file
162
Jarvis/Views/Shared/Theme.swift
Normal file
@@ -0,0 +1,162 @@
|
||||
// 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
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Palette
|
||||
|
||||
enum Palette {
|
||||
static let orange = Color("KisaniOrange") // #FF5C00
|
||||
static let black = Color.black // #000000
|
||||
static let surface = Color(hex: "111111")
|
||||
static let surface2 = Color(hex: "1A1A1A")
|
||||
static let hairline = Color(hex: "1A1A1A")
|
||||
|
||||
// health
|
||||
static let healthActive = Color(hex: "2A5A2A")
|
||||
static let healthFailing = Color(hex: "AA6600")
|
||||
static let healthDead = Color(hex: "5A1A1A")
|
||||
|
||||
// blocks
|
||||
static let consensusBorder = Color(hex: "FF5C00")
|
||||
static let consensusFill = Color(hex: "1F1206") // dark orange
|
||||
static let conflictBorder = Color(hex: "5A1A1A") // dark red
|
||||
static let conflictFill = Color(hex: "1A0C0C") // dark red
|
||||
|
||||
static let cachedGreen = Color(hex: "2A5A2A")
|
||||
static let bodyText = Color(hex: "4A4A4A")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// 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 .white }
|
||||
if score >= 20 {
|
||||
let t = Double(score - 20) / Double(97 - 20)
|
||||
return lerp((0x44/255, 0x44/255, 0x44/255), // #444444
|
||||
(1, 1, 1), // white
|
||||
t)
|
||||
}
|
||||
// 0–19 near invisible: #1C1C1C → #444444
|
||||
let t = Double(score) / 20.0
|
||||
return lerp((0x1C/255, 0x1C/255, 0x1C/255),
|
||||
(0x44/255, 0x44/255, 0x44/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 "finance": return "Finance"
|
||||
case "tech": return "Tech"
|
||||
case "politics": return "Politics"
|
||||
case "africa": return "Africa"
|
||||
default: return slug.prefix(1).uppercased() + slug.dropFirst()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
Reference in New Issue
Block a user