From 207b1827c71f7dbe05c8c332d0417d7a33dea052 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Sun, 21 Jun 2026 02:15:23 +0300 Subject: [PATCH] feat: hero card layout in all pills, cache-while-loading, logo splash screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Jarvis/JarvisApp.swift | 55 +++++++++++++++++++--- Jarvis/Views/Home/SignalFeedView.swift | 65 ++++++++------------------ Jarvis/Views/Shared/Theme.swift | 13 ++++++ 3 files changed, 81 insertions(+), 52 deletions(-) diff --git a/Jarvis/JarvisApp.swift b/Jarvis/JarvisApp.swift index 6c0192f..7a2f371 100644 --- a/Jarvis/JarvisApp.swift +++ b/Jarvis/JarvisApp.swift @@ -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) diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index 8e604d3..1561b0a 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -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 { Set(readStories.map(\.id)) } private var savedStoryIds: Set { 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,13 +242,7 @@ 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) @@ -283,28 +259,25 @@ struct SignalFeedView: View { } } - /// 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 } diff --git a/Jarvis/Views/Shared/Theme.swift b/Jarvis/Views/Shared/Theme.swift index 9b30c43..93a5e81 100644 --- a/Jarvis/Views/Shared/Theme.swift +++ b/Jarvis/Views/Shared/Theme.swift @@ -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