Files
jarvis/Jarvis/Views/SecondaryTabs.swift
Robin Kutesa 595f21fb6c
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
feat: East Africa + Southern Africa pills with regional sub-sections
- East Africa pill: merges Uganda + east-africa tags; Uganda pinned first via sub-sections
- Southern Africa pill: covers SA, Namibia, Botswana, Zimbabwe, Zambia, Mozambique; SA/Namibia/Botswana prioritized
- StoryStore.setPill now resets lastSyncedAt to bypass 30s rate-limit on filter switch
- sectionSupplement background fetch populates sparse regional sections in All digest
- SignalFeedView digestContent: flat hero+scroll path for pills without subSections (was showing max 5)
- Theme: removed standalone Uganda pill, wired new sub-section layouts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 02:33:33 +03:00

193 lines
6.6 KiB
Swift

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