// 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(Color(hex: "666666")) .padding(.top, 6) Spacer() } .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 12) } } private struct StoryList: View { let stories: [StorySummary] let cachedIds: Set var readIds: Set = [] 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 { Color.black.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 { Color.black.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(Color(hex: "333333")) Text("Nothing saved yet") .font(.system(size: 14, weight: .bold)) .foregroundStyle(Color(hex: "555555")) Text("Swipe a story left to save it") .font(.system(size: 12)) .foregroundStyle(Color(hex: "444444")) } .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 { Color.black.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(Color(hex: "555555")) TextField("", text: $query, prompt: Text("Search").foregroundColor(Color(hex: "555555"))) .font(.system(size: 15)) .foregroundStyle(.white) .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(Color(hex: "555555")) .frame(maxWidth: .infinity, maxHeight: .infinity) } }