Compare commits

...

3 Commits

Author SHA1 Message Date
Robin Kutesa
207b1827c7 feat: hero card layout in all pills, cache-while-loading, logo splash screen
Some checks failed
CI / Build · macos-14 · Debug (push) Has been cancelled
CI / Build · macos-15 · Debug (push) Has been cancelled
CI / Build · macos-14 · Release (push) Has been cancelled
CI / Build · macos-15 · Release (push) Has been cancelled
- Every pill (F1, Tech, AI, Cloud, HomeLab…) now shows the same hero card
  layout as "All" — first story is the big featured card, rest follow as rows
- "All" retains its full multi-section front page (US / Tech / AI / Sport…)
- Topic pill headers use the pill name instead of "TOP STORIES"
- filteredStories now falls back to cached SwiftData rows while loading, so
  switching to F1 or Tech shows cached cards instantly instead of a blank list
- Logo splash screen (3 pill bars + jarvis wordmark) shows on launch and fades
  out after setup completes; minimum 1.3 s so it's always readable
- JarvisWordmark moved to Theme.swift so SplashView and feed can share it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 02:15:23 +03:00
Robin Kutesa
760e6a121c fix: resolve Swift 6 concurrency warnings in WebSocketManager
- listen(): capture `task` as local `t` before the Sendable receive closure
  so the @MainActor-isolated property isn't referenced inside a Sendable context
- startPing(): dispatch to @MainActor inside the timer block before accessing `task`
- scheduleReconnect(): capture `self` weakly in the reconnect Task

XcodeGen regenerated to include BackgroundRefreshManager.swift in the project.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 02:06:01 +03:00
Robin Kutesa
eec610024c feat: read-shelf auto-expand, stripe removal, pull-reshuffle, BG notifications
- Mark-read now immediately expands the READ shelf so the story is visible
- Orange signal stripe is fully removed (not dimmed) when isRead=true
- Pull-to-refresh clears seen snapshot → full reshuffle by signal score
- BackgroundRefreshManager fires a local notification for the top new
  high-signal story after each cache refresh; Tech/AI/HomeLab stories
  get their category in the notification title (≥65 signal threshold)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 01:58:51 +03:00
6 changed files with 157 additions and 65 deletions

View File

@@ -5,6 +5,38 @@ import SwiftData
import UIKit
import BackgroundTasks
// MARK: - Logo + splash
/// The three-bar logo that mirrors the app icon.
struct JarvisLogoMark: View {
var width: CGFloat = 180
var body: some View {
VStack(alignment: .center, spacing: 8) {
// Orange (short) · White (longest) · Grey (medium) same ratios as icon
Capsule().fill(Palette.orange)
.frame(width: width * 0.48, height: width * 0.075)
.shadow(color: Palette.orange.opacity(0.55), radius: 10, y: 2)
Capsule().fill(Color.white)
.frame(width: width, height: width * 0.075)
Capsule().fill(Color(hex: "888888"))
.frame(width: width * 0.74, height: width * 0.075)
}
}
}
struct SplashView: View {
var body: some View {
ZStack {
Color(hex: "0A0A0A").ignoresSafeArea()
VStack(spacing: 28) {
JarvisLogoMark(width: 180)
JarvisWordmark(size: 34)
}
}
}
}
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
@@ -37,6 +69,7 @@ struct JarvisApp: App {
@StateObject private var connectivity = ConnectivitySettings.shared
@StateObject private var connManager = ConnectivityManager.shared
@StateObject private var notifications = NotificationManager.shared
@State private var showSplash = true
// Single ModelContainer instance shared with BackgroundRefreshManager.
private let container: ModelContainer = {
@@ -47,13 +80,22 @@ struct JarvisApp: App {
var body: some Scene {
WindowGroup {
Group {
if settings.isConfigured {
RootTabView()
ZStack {
if showSplash {
SplashView()
.transition(.opacity)
} else {
OnboardingView()
Group {
if settings.isConfigured {
RootTabView()
} else {
OnboardingView()
}
}
.transition(.opacity)
}
}
.animation(.easeOut(duration: 0.5), value: showSplash)
.preferredColorScheme(appearanceMode.colorScheme)
.environmentObject(settings)
.environmentObject(store)
@@ -62,13 +104,14 @@ struct JarvisApp: App {
.environmentObject(connManager)
.environmentObject(notifications)
.task {
// Wire the shared container before scheduling BG work.
BackgroundRefreshManager.container = container
BackgroundRefreshManager.scheduleFeedRefresh()
connManager.start()
await connManager.resolveAndActivate()
await notifications.bootstrap()
// Hold splash until setup is done, minimum 1.3 s for legibility.
try? await Task.sleep(nanoseconds: 1_300_000_000)
showSplash = false
}
}
.modelContainer(container)

View File

@@ -60,13 +60,16 @@ final class WebSocketManager: ObservableObject {
}
private func listen() {
task?.receive { [weak self] result in
guard let self else { return }
// 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 in self.handleDisconnect() }
Task { @MainActor [weak self] in self?.handleDisconnect() }
case .success(let message):
Task { @MainActor in
Task { @MainActor [weak self] in
guard let self else { return }
self.handle(message: message)
self.listen()
}
@@ -99,9 +102,13 @@ final class WebSocketManager: ObservableObject {
private func startPing() {
pingTimer?.invalidate()
pingTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
self?.task?.sendPing { error in
if error != nil {
Task { @MainActor in self?.handleDisconnect() }
// 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() }
}
}
}
}
@@ -119,10 +126,10 @@ final class WebSocketManager: ObservableObject {
let delay = min(pow(2.0, Double(attempt - 1)), maxBackoff)
connectionState = .reconnecting(attempt: attempt)
reconnectTask = Task {
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() }
await MainActor.run { self?.openConnection() }
}
}
}

View File

@@ -6,6 +6,7 @@
import Foundation
import BackgroundTasks
import SwiftData
import UserNotifications
let jarvisFeedRefreshID = "com.kisani.jarvis.feed.refresh"
@@ -47,25 +48,71 @@ enum BackgroundRefreshManager {
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 {
// Refresh signal score and freshness on already-cached rows.
existing.signalScore = story.signalScore
existing.sourceCount = story.sourceCount
existing.updatedAt = story.updatedAt
} else {
context.insert(CachedStory(from: story))
newStories.append(story)
}
}
try? context.save()
if !newStories.isEmpty {
notifyNewStories(newStories)
}
} catch {
// Network unavailable in background fail silently, next run will catch up.
}
}
// 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() {

View File

@@ -5,21 +5,6 @@
import SwiftUI
import SwiftData
// Reusable wordmark: typewriter font, lowercase j in orange, orange dot on i.
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("arv").font(font).foregroundStyle(.white)
Text("is").font(font).foregroundStyle(.white)
}
}
}
struct SignalFeedView: View {
@EnvironmentObject var store: StoryStore
@EnvironmentObject var ws: WebSocketManager
@@ -46,16 +31,14 @@ struct SignalFeedView: View {
private var readStoryIds: Set<String> { Set(readStories.map(\.id)) }
private var savedStoryIds: Set<String> { Set(savedStories.map(\.id)) }
/// Live stories when online; cached fallback when offline. Filtered by the
/// selected pill via backend tags no headline keyword matching.
/// Live stories when connected; cached fallback while loading or offline so
/// switching to Tech / AI / F1 shows cached cards instantly.
private var filteredStories: [StorySummary] {
let base: [StorySummary]
if !store.stories.isEmpty {
base = store.stories.sorted(by: StorySummary.feedOrder)
} else if !ws.connectionState.isLive {
base = cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
} else {
base = []
base = cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
}
return base.filter { $0.matches(selectedPill) }
}
@@ -73,8 +56,8 @@ struct SignalFeedView: View {
filteredStories.filter { readStoryIds.contains($0.id) }
}
/// "All" shows a sectioned front page; specific pills show a flat list.
private var isDigest: Bool { selectedPill == .all }
/// Every pill shows the card layout sections are scoped to "All".
private var isDigest: Bool { true }
/// Front-page digest: Top Stories + up to 3 per category section, deduped.
private var digest: (top: [StorySummary], sections: [(NewsSection, [StorySummary])]) {
@@ -125,7 +108,6 @@ struct SignalFeedView: View {
header
syncBar
topicPills
if !isDigest { columnHeaders } // sections carry their own headers
Divider().overlay(Palette.hairline)
feedList
}
@@ -260,47 +242,42 @@ struct SignalFeedView: View {
private var feedList: some View {
List {
if mainStories.isEmpty && readItems.isEmpty {
emptyState.plainBlackRow()
} else if isDigest {
digestContent
} else {
flatContent
}
digestContent
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.black)
.animation(.snappy, value: showRead)
.animation(.snappy, value: readStories.count)
.onAppear { captureSeenSnapshot() }
.refreshable {
captureSeenSnapshot() // sink what you've already seen
// Reshuffle: clear the seen snapshot so all stories re-rank by signal
// score, and any stories cached by background refresh float to the top.
seenSnapshot = []
seenTracker.ids = []
await store.loadStories(refresh: true)
}
}
/// Flat list for a specific pill.
@ViewBuilder private var flatContent: some View {
ForEach(mainStories) { mainRow($0) }
if mainStories.isEmpty { caughtUpRow.plainBlackRow() }
if store.isLoadingMore {
ProgressView().tint(Palette.orange)
.frame(maxWidth: .infinity).padding(.vertical, 20).plainBlackRow()
}
readShelf
}
/// Sectioned front page for "All".
/// Card layout for every pill. "All" gets full multi-section front page;
/// topic pills get a hero card + flat list within that topic.
@ViewBuilder private var digestContent: some View {
let d = digest
sectionHeader("TOP STORIES", pill: nil)
let headerLabel = selectedPill == .all
? "TOP STORIES"
: selectedPill.label.uppercased()
sectionHeader(headerLabel, pill: nil)
if let lead = d.top.first {
mainRow(lead, hero: true)
ForEach(d.top.dropFirst()) { mainRow($0) }
} else {
emptyState.plainBlackRow()
}
ForEach(d.sections, id: \.0.id) { section, stories in
sectionHeader(section.label.uppercased(), pill: section.pill)
ForEach(stories) { mainRow($0) }
if selectedPill == .all {
ForEach(d.sections, id: \.0.id) { section, stories in
sectionHeader(section.label.uppercased(), pill: section.pill)
ForEach(stories) { mainRow($0) }
}
}
readShelf
}
@@ -566,6 +543,7 @@ struct SignalFeedView: View {
if existing?.first == nil {
modelContext.insert(ReadStory(id: id))
try? modelContext.save()
withAnimation(.snappy) { showRead = true }
}
}

View File

@@ -41,7 +41,12 @@ struct StoryRowView: View {
private var fullBody: some View {
HStack(alignment: .top, spacing: 0) {
SignalStripe(score: story.signalScore, muted: isRead)
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) {
// Title (+ cached dot)
@@ -90,7 +95,6 @@ struct StoryRowView: View {
.padding(.trailing, 16)
.padding(.top, 16)
}
.opacity(isRead ? 0.55 : 1)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.black)
.overlay(alignment: .bottom) {

View File

@@ -264,6 +264,19 @@ extension StorySummary {
}
}
// 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("arvis").font(font).foregroundStyle(.white)
}
}
}
// MARK: - News sections (the "All" front page)
//
// Groups the firehose into Apple/Google-News-style sections. A story lands in