Equal-score stories reshuffled on every pull-to-refresh because the sort was unstable. Add StorySummary.feedOrder (signalScore desc, then id desc, mirroring the backend's signal_score DESC, id DESC) and use it for the feed, the WebSocket re-sort, and the Saved/Search tabs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
188 lines
6.3 KiB
Swift
188 lines
6.3 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(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<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 {
|
|
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()
|
|
}
|
|
.preferredColorScheme(.dark)
|
|
}
|
|
}
|
|
|
|
// MARK: - Saved (offline cache)
|
|
|
|
struct SavedView: View {
|
|
@Query private var cachedStories: [CachedStory]
|
|
@Query private var cachedArticles: [CachedArticle]
|
|
@Query private var readStories: [ReadStory]
|
|
|
|
private var stories: [StorySummary] {
|
|
cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
Color.black.ignoresSafeArea()
|
|
VStack(spacing: 0) {
|
|
TabHeader(title: "Saved · offline")
|
|
Divider().overlay(Palette.hairline)
|
|
if stories.isEmpty {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "bookmark")
|
|
.font(.system(size: 28)).foregroundStyle(Color(hex: "333333"))
|
|
Text("Nothing cached yet")
|
|
.font(.system(size: 14, weight: .bold))
|
|
.foregroundStyle(Color(hex: "555555"))
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
StoryList(stories: stories, cachedIds: Set(cachedArticles.map(\.storyId)),
|
|
readIds: Set(readStories.map(\.id)))
|
|
}
|
|
}
|
|
}
|
|
.navigationBarHidden(true)
|
|
.jarvisDestinations()
|
|
}
|
|
.preferredColorScheme(.dark)
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
.preferredColorScheme(.dark)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|