Compare commits
3 Commits
3b09fa2889
...
207b1827c7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
207b1827c7 | ||
|
|
760e6a121c | ||
|
|
eec610024c |
@@ -5,6 +5,38 @@ import SwiftData
|
|||||||
import UIKit
|
import UIKit
|
||||||
import BackgroundTasks
|
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 {
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
func application(_ application: UIApplication,
|
func application(_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||||
@@ -37,6 +69,7 @@ struct JarvisApp: App {
|
|||||||
@StateObject private var connectivity = ConnectivitySettings.shared
|
@StateObject private var connectivity = ConnectivitySettings.shared
|
||||||
@StateObject private var connManager = ConnectivityManager.shared
|
@StateObject private var connManager = ConnectivityManager.shared
|
||||||
@StateObject private var notifications = NotificationManager.shared
|
@StateObject private var notifications = NotificationManager.shared
|
||||||
|
@State private var showSplash = true
|
||||||
|
|
||||||
// Single ModelContainer instance shared with BackgroundRefreshManager.
|
// Single ModelContainer instance shared with BackgroundRefreshManager.
|
||||||
private let container: ModelContainer = {
|
private let container: ModelContainer = {
|
||||||
@@ -47,13 +80,22 @@ struct JarvisApp: App {
|
|||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
Group {
|
ZStack {
|
||||||
if settings.isConfigured {
|
if showSplash {
|
||||||
RootTabView()
|
SplashView()
|
||||||
|
.transition(.opacity)
|
||||||
} else {
|
} else {
|
||||||
OnboardingView()
|
Group {
|
||||||
|
if settings.isConfigured {
|
||||||
|
RootTabView()
|
||||||
|
} else {
|
||||||
|
OnboardingView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.animation(.easeOut(duration: 0.5), value: showSplash)
|
||||||
.preferredColorScheme(appearanceMode.colorScheme)
|
.preferredColorScheme(appearanceMode.colorScheme)
|
||||||
.environmentObject(settings)
|
.environmentObject(settings)
|
||||||
.environmentObject(store)
|
.environmentObject(store)
|
||||||
@@ -62,13 +104,14 @@ struct JarvisApp: App {
|
|||||||
.environmentObject(connManager)
|
.environmentObject(connManager)
|
||||||
.environmentObject(notifications)
|
.environmentObject(notifications)
|
||||||
.task {
|
.task {
|
||||||
// Wire the shared container before scheduling BG work.
|
|
||||||
BackgroundRefreshManager.container = container
|
BackgroundRefreshManager.container = container
|
||||||
BackgroundRefreshManager.scheduleFeedRefresh()
|
BackgroundRefreshManager.scheduleFeedRefresh()
|
||||||
|
|
||||||
connManager.start()
|
connManager.start()
|
||||||
await connManager.resolveAndActivate()
|
await connManager.resolveAndActivate()
|
||||||
await notifications.bootstrap()
|
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)
|
.modelContainer(container)
|
||||||
|
|||||||
@@ -60,13 +60,16 @@ final class WebSocketManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func listen() {
|
private func listen() {
|
||||||
task?.receive { [weak self] result in
|
// Capture task locally so the Sendable receive closure doesn't reference
|
||||||
guard let self else { return }
|
// the @MainActor-isolated property directly.
|
||||||
|
guard let t = task else { return }
|
||||||
|
t.receive { [weak self] result in
|
||||||
switch result {
|
switch result {
|
||||||
case .failure:
|
case .failure:
|
||||||
Task { @MainActor in self.handleDisconnect() }
|
Task { @MainActor [weak self] in self?.handleDisconnect() }
|
||||||
case .success(let message):
|
case .success(let message):
|
||||||
Task { @MainActor in
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
self.handle(message: message)
|
self.handle(message: message)
|
||||||
self.listen()
|
self.listen()
|
||||||
}
|
}
|
||||||
@@ -99,9 +102,13 @@ final class WebSocketManager: ObservableObject {
|
|||||||
private func startPing() {
|
private func startPing() {
|
||||||
pingTimer?.invalidate()
|
pingTimer?.invalidate()
|
||||||
pingTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
|
pingTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
|
||||||
self?.task?.sendPing { error in
|
// Hop to MainActor before touching the isolated `task` property.
|
||||||
if error != nil {
|
Task { @MainActor [weak self] in
|
||||||
Task { @MainActor in self?.handleDisconnect() }
|
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)
|
let delay = min(pow(2.0, Double(attempt - 1)), maxBackoff)
|
||||||
connectionState = .reconnecting(attempt: attempt)
|
connectionState = .reconnecting(attempt: attempt)
|
||||||
|
|
||||||
reconnectTask = Task {
|
reconnectTask = Task { [weak self] in
|
||||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
await MainActor.run { self.openConnection() }
|
await MainActor.run { self?.openConnection() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
let jarvisFeedRefreshID = "com.kisani.jarvis.feed.refresh"
|
let jarvisFeedRefreshID = "com.kisani.jarvis.feed.refresh"
|
||||||
|
|
||||||
@@ -47,25 +48,71 @@ enum BackgroundRefreshManager {
|
|||||||
let page = try await APIClient.shared.fetchStories(limit: 40)
|
let page = try await APIClient.shared.fetchStories(limit: 40)
|
||||||
let context = ModelContext(container)
|
let context = ModelContext(container)
|
||||||
|
|
||||||
|
var newStories: [StorySummary] = []
|
||||||
|
|
||||||
for story in page.data {
|
for story in page.data {
|
||||||
let id = story.id
|
let id = story.id
|
||||||
if let existing = (try? context.fetch(
|
if let existing = (try? context.fetch(
|
||||||
FetchDescriptor<CachedStory>(predicate: #Predicate { $0.id == id })))?.first {
|
FetchDescriptor<CachedStory>(predicate: #Predicate { $0.id == id })))?.first {
|
||||||
// Refresh signal score and freshness on already-cached rows.
|
|
||||||
existing.signalScore = story.signalScore
|
existing.signalScore = story.signalScore
|
||||||
existing.sourceCount = story.sourceCount
|
existing.sourceCount = story.sourceCount
|
||||||
existing.updatedAt = story.updatedAt
|
existing.updatedAt = story.updatedAt
|
||||||
} else {
|
} else {
|
||||||
context.insert(CachedStory(from: story))
|
context.insert(CachedStory(from: story))
|
||||||
|
newStories.append(story)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try? context.save()
|
try? context.save()
|
||||||
|
|
||||||
|
if !newStories.isEmpty {
|
||||||
|
notifyNewStories(newStories)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Network unavailable in background — fail silently, next run will catch up.
|
// 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
|
// MARK: - Schedule
|
||||||
|
|
||||||
static func scheduleFeedRefresh() {
|
static func scheduleFeedRefresh() {
|
||||||
|
|||||||
@@ -5,21 +5,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
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 {
|
struct SignalFeedView: View {
|
||||||
@EnvironmentObject var store: StoryStore
|
@EnvironmentObject var store: StoryStore
|
||||||
@EnvironmentObject var ws: WebSocketManager
|
@EnvironmentObject var ws: WebSocketManager
|
||||||
@@ -46,16 +31,14 @@ struct SignalFeedView: View {
|
|||||||
private var readStoryIds: Set<String> { Set(readStories.map(\.id)) }
|
private var readStoryIds: Set<String> { Set(readStories.map(\.id)) }
|
||||||
private var savedStoryIds: Set<String> { Set(savedStories.map(\.id)) }
|
private var savedStoryIds: Set<String> { Set(savedStories.map(\.id)) }
|
||||||
|
|
||||||
/// Live stories when online; cached fallback when offline. Filtered by the
|
/// Live stories when connected; cached fallback while loading or offline so
|
||||||
/// selected pill via backend tags — no headline keyword matching.
|
/// switching to Tech / AI / F1 shows cached cards instantly.
|
||||||
private var filteredStories: [StorySummary] {
|
private var filteredStories: [StorySummary] {
|
||||||
let base: [StorySummary]
|
let base: [StorySummary]
|
||||||
if !store.stories.isEmpty {
|
if !store.stories.isEmpty {
|
||||||
base = store.stories.sorted(by: StorySummary.feedOrder)
|
base = store.stories.sorted(by: StorySummary.feedOrder)
|
||||||
} else if !ws.connectionState.isLive {
|
|
||||||
base = cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
|
|
||||||
} else {
|
} else {
|
||||||
base = []
|
base = cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
|
||||||
}
|
}
|
||||||
return base.filter { $0.matches(selectedPill) }
|
return base.filter { $0.matches(selectedPill) }
|
||||||
}
|
}
|
||||||
@@ -73,8 +56,8 @@ struct SignalFeedView: View {
|
|||||||
filteredStories.filter { readStoryIds.contains($0.id) }
|
filteredStories.filter { readStoryIds.contains($0.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "All" shows a sectioned front page; specific pills show a flat list.
|
/// Every pill shows the card layout — sections are scoped to "All".
|
||||||
private var isDigest: Bool { selectedPill == .all }
|
private var isDigest: Bool { true }
|
||||||
|
|
||||||
/// Front-page digest: Top Stories + up to 3 per category section, deduped.
|
/// Front-page digest: Top Stories + up to 3 per category section, deduped.
|
||||||
private var digest: (top: [StorySummary], sections: [(NewsSection, [StorySummary])]) {
|
private var digest: (top: [StorySummary], sections: [(NewsSection, [StorySummary])]) {
|
||||||
@@ -125,7 +108,6 @@ struct SignalFeedView: View {
|
|||||||
header
|
header
|
||||||
syncBar
|
syncBar
|
||||||
topicPills
|
topicPills
|
||||||
if !isDigest { columnHeaders } // sections carry their own headers
|
|
||||||
Divider().overlay(Palette.hairline)
|
Divider().overlay(Palette.hairline)
|
||||||
feedList
|
feedList
|
||||||
}
|
}
|
||||||
@@ -260,47 +242,42 @@ struct SignalFeedView: View {
|
|||||||
|
|
||||||
private var feedList: some View {
|
private var feedList: some View {
|
||||||
List {
|
List {
|
||||||
if mainStories.isEmpty && readItems.isEmpty {
|
digestContent
|
||||||
emptyState.plainBlackRow()
|
|
||||||
} else if isDigest {
|
|
||||||
digestContent
|
|
||||||
} else {
|
|
||||||
flatContent
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
.animation(.snappy, value: showRead)
|
.animation(.snappy, value: showRead)
|
||||||
|
.animation(.snappy, value: readStories.count)
|
||||||
.onAppear { captureSeenSnapshot() }
|
.onAppear { captureSeenSnapshot() }
|
||||||
.refreshable {
|
.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)
|
await store.loadStories(refresh: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flat list for a specific pill.
|
/// Card layout for every pill. "All" gets full multi-section front page;
|
||||||
@ViewBuilder private var flatContent: some View {
|
/// topic pills get a hero card + flat list within that topic.
|
||||||
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".
|
|
||||||
@ViewBuilder private var digestContent: some View {
|
@ViewBuilder private var digestContent: some View {
|
||||||
let d = digest
|
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 {
|
if let lead = d.top.first {
|
||||||
mainRow(lead, hero: true)
|
mainRow(lead, hero: true)
|
||||||
ForEach(d.top.dropFirst()) { mainRow($0) }
|
ForEach(d.top.dropFirst()) { mainRow($0) }
|
||||||
|
} else {
|
||||||
|
emptyState.plainBlackRow()
|
||||||
}
|
}
|
||||||
ForEach(d.sections, id: \.0.id) { section, stories in
|
if selectedPill == .all {
|
||||||
sectionHeader(section.label.uppercased(), pill: section.pill)
|
ForEach(d.sections, id: \.0.id) { section, stories in
|
||||||
ForEach(stories) { mainRow($0) }
|
sectionHeader(section.label.uppercased(), pill: section.pill)
|
||||||
|
ForEach(stories) { mainRow($0) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
readShelf
|
readShelf
|
||||||
}
|
}
|
||||||
@@ -566,6 +543,7 @@ struct SignalFeedView: View {
|
|||||||
if existing?.first == nil {
|
if existing?.first == nil {
|
||||||
modelContext.insert(ReadStory(id: id))
|
modelContext.insert(ReadStory(id: id))
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
|
withAnimation(.snappy) { showRead = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,12 @@ struct StoryRowView: View {
|
|||||||
|
|
||||||
private var fullBody: some View {
|
private var fullBody: some View {
|
||||||
HStack(alignment: .top, spacing: 0) {
|
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) {
|
VStack(alignment: .leading, spacing: 9) {
|
||||||
// Title (+ cached dot)
|
// Title (+ cached dot)
|
||||||
@@ -90,7 +95,6 @@ struct StoryRowView: View {
|
|||||||
.padding(.trailing, 16)
|
.padding(.trailing, 16)
|
||||||
.padding(.top, 16)
|
.padding(.top, 16)
|
||||||
}
|
}
|
||||||
.opacity(isRead ? 0.55 : 1)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
|
|||||||
@@ -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)
|
// MARK: - News sections (the "All" front page)
|
||||||
//
|
//
|
||||||
// Groups the firehose into Apple/Google-News-style sections. A story lands in
|
// Groups the firehose into Apple/Google-News-style sections. A story lands in
|
||||||
|
|||||||
Reference in New Issue
Block a user