rename: Jarvis -> Jervis target, scheme, and folder throughout
The bundle-ID and display-name renames weren't enough — the actual Xcode target/product name was still "Jarvis", which drives CFBundleName, Xcode's Organizer archive list, the .xcodeproj filename, and the scheme name. Renamed all the way through: - project.yml: top-level name, target key, PRODUCT_NAME, source/info paths - Jarvis/ -> Jervis/ (source folder, git-tracked as renames, no content diffs on the moved files) - .gitignore, CI workflows, README, CONTRIBUTING: Jarvis.xcodeproj / scheme Jarvis -> Jervis.xcodeproj / scheme Jervis Verified: xcodebuild -scheme Jervis succeeds, produces Jervis.app, CFBundleName/CFBundleExecutable/CFBundleDisplayName all read "Jervis". CFBundleIdentifier intentionally stays com.kisani.jarvis (per prior decision — bundle ID isn't user-facing anywhere including Organizer). Swift type names (JarvisApp, JarvisWordmark, etc.) and the Gitea repo name/URL are unchanged — pure internal source identifiers, not user or developer-facing product identity. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
793
Jervis/Views/Home/SignalFeedView.swift
Normal file
793
Jervis/Views/Home/SignalFeedView.swift
Normal file
@@ -0,0 +1,793 @@
|
||||
// SignalFeedView.swift
|
||||
// Jarvis — home screen. Stories ranked by signal score, live connection state,
|
||||
// topic filters, pull-to-refresh, infinite scroll.
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct SignalFeedView: View {
|
||||
@EnvironmentObject var store: StoryStore
|
||||
@EnvironmentObject var ws: WebSocketManager
|
||||
@EnvironmentObject var settings: ServerSettings
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@Query private var cachedStories: [CachedStory]
|
||||
@Query private var cachedArticles: [CachedArticle]
|
||||
@Query private var readStories: [ReadStory]
|
||||
@Query private var savedStories: [SavedStory]
|
||||
@State private var showFeeds = false
|
||||
@State private var showSettings = false
|
||||
@State private var selectedPill: StoryPill = .all
|
||||
@State private var shareStory: StorySummary?
|
||||
/// Snapshot of already-seen story ids, captured per load so the order stays
|
||||
/// stable while scrolling (and refreshes to sink newly-seen ones).
|
||||
@State private var seenSnapshot: Set<String> = []
|
||||
/// Reference-type set so marking a story seen on scroll doesn't re-render.
|
||||
@State private var seenTracker = SeenTracker()
|
||||
@State private var showRead = false
|
||||
@State private var markAllToast: String?
|
||||
@State private var markAllPending = false
|
||||
|
||||
/// Stories that have full article content cached → eligible for the green dot.
|
||||
private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) }
|
||||
private var readStoryIds: Set<String> { Set(readStories.map(\.id)) }
|
||||
private var savedStoryIds: Set<String> { Set(savedStories.map(\.id)) }
|
||||
|
||||
/// Live stories when connected; cached fallback while loading or offline so
|
||||
/// switching to Tech / AI / F1 shows cached cards instantly.
|
||||
/// Before the first sync of this session completes, only unread stories are
|
||||
/// surfaced — read content is irrelevant until we know there's nothing newer.
|
||||
private var filteredStories: [StorySummary] {
|
||||
let base: [StorySummary]
|
||||
if !store.stories.isEmpty {
|
||||
base = store.stories.sorted(by: StorySummary.feedOrder)
|
||||
} else {
|
||||
base = cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
|
||||
}
|
||||
let matched = base.filter { $0.matches(selectedPill) }
|
||||
if store.lastSyncedAt == nil {
|
||||
return matched.filter { !readStoryIds.contains($0.id) }
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
/// The main feed: unread stories, with already-seen ones sunk below fresh
|
||||
/// ones, and fossils (old by firstSeenAt, see StorySummary.isFossil) sunk
|
||||
/// below everything — a high signalScore alone can't make old news lead.
|
||||
private var mainStories: [StorySummary] {
|
||||
let notRead = filteredStories.filter { !readStoryIds.contains($0.id) }
|
||||
let current = notRead.filter { !$0.isFossil }
|
||||
let fossils = notRead.filter { $0.isFossil }
|
||||
let fresh = current.filter { !seenSnapshot.contains($0.id) }
|
||||
let seen = current.filter { seenSnapshot.contains($0.id) }
|
||||
return fresh + seen + fossils
|
||||
}
|
||||
|
||||
/// Read stories, tucked into the collapsible "Read" shelf.
|
||||
private var readItems: [StorySummary] {
|
||||
filteredStories.filter { readStoryIds.contains($0.id) }
|
||||
}
|
||||
|
||||
/// Every pill shows the card layout — sections are scoped to "All".
|
||||
private var isDigest: Bool { true }
|
||||
|
||||
/// Front-page digest: Top Stories + up to 4 per category section, deduped.
|
||||
/// Sections that score poorly in the global top-100 are supplemented from
|
||||
/// per-section targeted fetches held in store.sectionSupplement.
|
||||
private func makeDigest(sections: [NewsSection]) -> (top: [StorySummary], sections: [(NewsSection, [StorySummary])]) {
|
||||
let unread = filteredStories.filter { !readStoryIds.contains($0.id) }
|
||||
// Fossils (old by firstSeenAt) sink below current stories so the lead
|
||||
// slot is never a high-scoring but stale story — see StorySummary.isFossil.
|
||||
let current = unread.filter { !$0.isFossil }
|
||||
let fossils = unread.filter { $0.isFossil }
|
||||
let reordered = current + fossils
|
||||
let top = Array(reordered.prefix(5))
|
||||
var pool = Array(reordered.dropFirst(5))
|
||||
var usedIds = Set(top.map(\.id))
|
||||
var out: [(NewsSection, [StorySummary])] = []
|
||||
for section in sections {
|
||||
var picked: [StorySummary] = [], rest: [StorySummary] = []
|
||||
for s in pool {
|
||||
if picked.count < 4 && s.inSection(section) { picked.append(s) }
|
||||
else { rest.append(s) }
|
||||
}
|
||||
pool = rest
|
||||
// Supplement from section-specific fetch when main pool is sparse
|
||||
if picked.count < 2, let extra = store.sectionSupplement[section.id] {
|
||||
for s in extra where !usedIds.contains(s.id) && s.inSection(section) {
|
||||
picked.append(s)
|
||||
usedIds.insert(s.id)
|
||||
if picked.count == 4 { break }
|
||||
}
|
||||
}
|
||||
usedIds.formUnion(picked.map(\.id))
|
||||
if !picked.isEmpty { out.append((section, picked)) }
|
||||
}
|
||||
return (top, out)
|
||||
}
|
||||
|
||||
private func captureSeenSnapshot() {
|
||||
let ids = (try? modelContext.fetch(FetchDescriptor<SeenStory>()))?.map(\.id) ?? []
|
||||
seenSnapshot = Set(ids)
|
||||
seenTracker.ids = seenSnapshot
|
||||
}
|
||||
|
||||
/// Hot path — runs as each row scrolls in. O(1), no disk write, no re-render:
|
||||
/// the container autosaves, and SeenStory isn't @Query'd by this view.
|
||||
private func markSeen(_ s: StorySummary) {
|
||||
guard seenTracker.ids.insert(s.id).inserted else { return }
|
||||
modelContext.insert(SeenStory(id: s.id))
|
||||
}
|
||||
|
||||
private func markUnread(_ s: StorySummary) {
|
||||
let id = s.id
|
||||
if let row = (try? modelContext.fetch(
|
||||
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
||||
modelContext.delete(row)
|
||||
try? modelContext.save()
|
||||
Task { try? await APIClient.shared.markStoryUnread(id: id) }
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack(alignment: .bottom) {
|
||||
Palette.background.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
syncBar
|
||||
topicPills
|
||||
Divider().overlay(Palette.hairline)
|
||||
feedList
|
||||
}
|
||||
|
||||
if let toast = markAllToast {
|
||||
Text(toast)
|
||||
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(Color(hex: "E0E0E0"))
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 11)
|
||||
.background(Color(hex: "1E1E1E"))
|
||||
.clipShape(Capsule())
|
||||
.overlay(Capsule().stroke(Color(hex: "333333"), lineWidth: 0.5))
|
||||
.shadow(color: .black.opacity(0.4), radius: 12, y: 4)
|
||||
.padding(.bottom, 24)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
|
||||
withAnimation(.easeOut(duration: 0.25)) { markAllToast = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.navigationDestination(for: StorySummary.self) { story in
|
||||
StoryDetailView(story: story)
|
||||
}
|
||||
.navigationDestination(for: ArticleRoute.self) { route in
|
||||
ArticleReaderView(route: route)
|
||||
}
|
||||
.sheet(isPresented: $showFeeds) {
|
||||
FeedManagerView()
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
.sheet(item: $shareStory) { story in
|
||||
ShareSheet(items: shareItems(story))
|
||||
}
|
||||
}
|
||||
.onChange(of: store.stories) { _, newValue in
|
||||
cacheStories(newValue)
|
||||
}
|
||||
.task { CacheMaintenance.prune(modelContext) } // bound the caches on launch
|
||||
.task(id: selectedPill) {
|
||||
await store.setPill(selectedPill) // paginates to 100 stories internally
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
JarvisWordmark(size: 26)
|
||||
Spacer()
|
||||
// Connection + controls as one coherent surface
|
||||
HStack(spacing: 2) {
|
||||
ConnectionBanner(state: ws.connectionState)
|
||||
Button {
|
||||
showFeeds = true
|
||||
} label: {
|
||||
Image(systemName: "dot.radiowaves.up.forward")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(Palette.secondaryText)
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
Menu {
|
||||
Button {
|
||||
Task { await markAllRead() }
|
||||
} label: {
|
||||
Label(
|
||||
markAllPending ? "Marking…" : "Mark all as read",
|
||||
systemImage: markAllPending ? "hourglass" : "checkmark.circle"
|
||||
)
|
||||
}
|
||||
.disabled(markAllPending || mainStories.isEmpty)
|
||||
Divider()
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(Palette.secondaryText)
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// MARK: - Sync bar
|
||||
|
||||
/// True while the first sync of this session hasn't completed yet, OR while
|
||||
/// a fetch is actively in flight. Used to gate the "caught up" state so we
|
||||
/// never declare victory before we've actually checked the server.
|
||||
private var isSyncing: Bool { store.isLoading || store.lastSyncedAt == nil }
|
||||
|
||||
private var syncBar: some View {
|
||||
HStack(spacing: 5) {
|
||||
if isSyncing {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.controlSize(.mini)
|
||||
.tint(Palette.orange)
|
||||
}
|
||||
Text(isSyncing ? "Syncing signals…" : syncText)
|
||||
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(Color(hex: "5A5A5A"))
|
||||
.animation(.easeInOut(duration: 0.15), value: store.isLoading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
|
||||
private var syncText: String {
|
||||
guard let last = store.lastSyncedAt else { return "Loading from cache…" }
|
||||
let ago = last.timeAgoShort()
|
||||
let phrase = ago == "just now" ? ago : "\(ago) ago"
|
||||
if ws.connectionState.isLive {
|
||||
return "Synced \(phrase) · \(cachedStoryIds.count) cached"
|
||||
} else {
|
||||
return "Last synced \(phrase)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pills
|
||||
|
||||
private var topicPills: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(StoryPill.allCases) { p in
|
||||
pill(p.label, selected: selectedPill == p) {
|
||||
selectedPill = p
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
|
||||
private func pill(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.system(size: 12, weight: selected ? .semibold : .medium))
|
||||
.foregroundStyle(selected ? .black : Palette.secondaryText)
|
||||
.padding(.horizontal, 13)
|
||||
.frame(height: 28)
|
||||
.background(selected ? Palette.orange : Palette.surface)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Column headers
|
||||
|
||||
private var columnHeaders: some View {
|
||||
HStack {
|
||||
Text("STORY")
|
||||
.kerning(0.8)
|
||||
Spacer()
|
||||
Text("SIGNAL ↓")
|
||||
.kerning(0.8)
|
||||
}
|
||||
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(Palette.tertiaryText)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
// MARK: - Feed list
|
||||
|
||||
private var feedList: some View {
|
||||
List {
|
||||
digestContent
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Palette.background)
|
||||
.animation(.snappy, value: showRead)
|
||||
.animation(.snappy, value: readStories.count)
|
||||
.onAppear {
|
||||
captureSeenSnapshot()
|
||||
// If the cache already shows all-read on first render, open the
|
||||
// shelf immediately so the user sees their previous stories.
|
||||
if !readItems.isEmpty && mainStories.isEmpty { showRead = true }
|
||||
}
|
||||
// Expand shelf when loading finishes and there are still no unread stories.
|
||||
.onChange(of: store.isLoading) { _, loading in
|
||||
if !loading, !readItems.isEmpty, mainStories.isEmpty {
|
||||
withAnimation(.snappy) { showRead = true }
|
||||
}
|
||||
}
|
||||
// Expand shelf if SwiftData @Query hydrates after the first render and
|
||||
// all cached stories turn out to already be read.
|
||||
.onChange(of: readItems.count) { _, count in
|
||||
if count > 0, mainStories.isEmpty, !showRead {
|
||||
showRead = true
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
seenSnapshot = []
|
||||
seenTracker.ids = []
|
||||
await store.loadFeedFull()
|
||||
// Merge any stories cached by background refresh that aren't in
|
||||
// the live feed yet — mergeStories deduplicates, so always safe.
|
||||
let liveIds = Set(store.stories.map(\.id))
|
||||
let extra = cachedStories
|
||||
.filter { !liveIds.contains($0.id) }
|
||||
.map(StorySummary.init(cached:))
|
||||
if !extra.isEmpty {
|
||||
await store.mergeStories(extra)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Card layout for every pill.
|
||||
/// "All" → multi-section digest. Tech (sub-sections) → sub-section digest.
|
||||
/// Everything else → hero card + flat scroll of all stories for that pill.
|
||||
@ViewBuilder private var digestContent: some View {
|
||||
if selectedPill == .all {
|
||||
let d = makeDigest(sections: NewsSection.sections)
|
||||
sectionHeader("TOP STORIES", pill: nil)
|
||||
pillContent(top: d.top)
|
||||
ForEach(d.sections, id: \.0.id) { section, stories in
|
||||
sectionHeader(section.label.uppercased(), pill: section.pill)
|
||||
ForEach(stories) { mainRow($0) }
|
||||
}
|
||||
} else if let subSections = selectedPill.subSections {
|
||||
let d = makeDigest(sections: subSections)
|
||||
sectionHeader(selectedPill.label.uppercased(), pill: nil)
|
||||
pillContent(top: d.top)
|
||||
ForEach(d.sections, id: \.0.id) { section, stories in
|
||||
sectionHeader(section.label.uppercased(), pill: section.pill)
|
||||
ForEach(stories) { mainRow($0) }
|
||||
}
|
||||
} else {
|
||||
// Flat list: hero + all stories for this pill
|
||||
sectionHeader(selectedPill.label.uppercased(), pill: nil)
|
||||
pillContent(top: mainStories, flat: true)
|
||||
}
|
||||
readShelf
|
||||
}
|
||||
|
||||
/// Shared hero + body rows. `flat` = render all items; otherwise only top 5.
|
||||
@ViewBuilder private func pillContent(top stories: [StorySummary], flat: Bool = false) -> some View {
|
||||
if let lead = stories.first {
|
||||
mainRow(lead, hero: true)
|
||||
let rest = flat ? Array(stories.dropFirst()) : Array(stories.dropFirst().prefix(4))
|
||||
ForEach(rest) { mainRow($0) }
|
||||
} else if !filteredStories.isEmpty {
|
||||
if isSyncing { checkingRow.plainBlackRow() }
|
||||
else { caughtUpRow.plainBlackRow() }
|
||||
} else {
|
||||
emptyState.plainBlackRow()
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionHeader(_ label: String, pill: StoryPill?) -> some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(label)
|
||||
.font(.system(size: 13, weight: .heavy, design: .monospaced))
|
||||
.kerning(0.6)
|
||||
.foregroundStyle(Palette.primaryText)
|
||||
Rectangle().fill(Palette.orange).frame(width: 18, height: 2)
|
||||
Spacer()
|
||||
if let pill {
|
||||
Button {
|
||||
selectedPill = pill
|
||||
} label: {
|
||||
HStack(spacing: 2) {
|
||||
Text("See all"); Image(systemName: "chevron.right").font(.system(size: 10, weight: .bold))
|
||||
}
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(Palette.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.top, 22).padding(.bottom, 10)
|
||||
.plainBlackRow()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func mainRow(_ story: StorySummary, hero: Bool = false) -> some View {
|
||||
NavigationLink(value: story) {
|
||||
if hero {
|
||||
heroCard(story)
|
||||
} else {
|
||||
StoryRowView(story: story, isCached: cachedStoryIds.contains(story.id))
|
||||
}
|
||||
}
|
||||
.listRowInsets(hero ? EdgeInsets(top: 2, leading: 16, bottom: 12, trailing: 16) : EdgeInsets())
|
||||
.listRowBackground(Palette.background)
|
||||
.listRowSeparator(.hidden)
|
||||
.onAppear {
|
||||
markSeen(story)
|
||||
if story.id == mainStories.last?.id { Task { await store.loadMore() } }
|
||||
}
|
||||
// Swipe left: short swipe reveals Save (tap); a full/long swipe marks read.
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button { markRead(story) } label: {
|
||||
Label("Mark read", systemImage: "checkmark.circle")
|
||||
}
|
||||
.tint(Color(hex: "3A5A7A"))
|
||||
Button { toggleSave(story) } label: {
|
||||
Label(isSaved(story) ? "Saved" : "Save",
|
||||
systemImage: isSaved(story) ? "bookmark.fill" : "bookmark")
|
||||
}
|
||||
.tint(Palette.healthActive)
|
||||
}
|
||||
// Swipe right → Share.
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
Button { markRead(story); shareStory = story } label: {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.tint(Palette.orange)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apple-News-style lead card for the #1 Top Story — flat surface, big type.
|
||||
private func heroCard(_ story: StorySummary) -> some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
SignalStripe(score: story.signalScore, width: 4)
|
||||
VStack(alignment: .leading, spacing: 11) {
|
||||
HStack(alignment: .center) {
|
||||
Text(Topic.label(story.topic).uppercased())
|
||||
.font(.system(size: 10, weight: .bold, design: .monospaced)).kerning(0.8)
|
||||
.foregroundStyle(Palette.secondaryText)
|
||||
Spacer()
|
||||
Text("\(story.signalScore)")
|
||||
.font(.system(size: 15, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(Signal.scoreColor(story.signalScore))
|
||||
}
|
||||
Text(story.headline)
|
||||
.font(.system(size: 25, weight: .bold, design: .default))
|
||||
.foregroundStyle(Palette.primaryText)
|
||||
.lineLimit(4)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if !story.summary.isEmpty {
|
||||
Text(story.summary)
|
||||
.font(.system(size: 16, weight: .regular, design: .default))
|
||||
.foregroundStyle(Palette.bodyText)
|
||||
.lineLimit(3).lineSpacing(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
if !story.sources.isEmpty {
|
||||
SourceChips(names: story.sources.map(\.name), total: story.sourceCount)
|
||||
}
|
||||
Text("\(story.sourceCount) sources · \((story.firstSeenAt ?? story.updatedAt).timeAgoShort()) ago")
|
||||
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(Palette.tertiaryText)
|
||||
}
|
||||
.padding(16)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.background(Palette.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Palette.surface2, lineWidth: 0.5))
|
||||
}
|
||||
|
||||
/// Shown when all cached stories are read AND the first sync hasn't finished yet.
|
||||
/// Keeps the UI honest — we haven't confirmed "no new content" until the server responds.
|
||||
private var checkingRow: some View {
|
||||
VStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.controlSize(.mini)
|
||||
.tint(Palette.orange)
|
||||
Text("Checking for new signals…")
|
||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(Palette.tertiaryText)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 50)
|
||||
}
|
||||
|
||||
private var caughtUpRow: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.circle")
|
||||
.font(.system(size: 32)).foregroundStyle(Color(hex: "3E7E3E"))
|
||||
Text("You're all caught up").font(.system(size: 16, weight: .semibold, design: .default)).foregroundStyle(Palette.primaryText)
|
||||
Text("Pull to refresh for more")
|
||||
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
||||
}
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 70)
|
||||
}
|
||||
|
||||
// Collapsible "Read" shelf at the bottom — read stories as slim rows.
|
||||
@ViewBuilder
|
||||
private var readShelf: some View {
|
||||
if !readItems.isEmpty {
|
||||
Button {
|
||||
withAnimation(.snappy) { showRead.toggle() }
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 13)).foregroundStyle(Color(hex: "3E7E3E"))
|
||||
Text("READ").font(.system(size: 11, weight: .bold, design: .monospaced)).kerning(0.8)
|
||||
Text("\(readItems.count)")
|
||||
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(Color(hex: "888888"))
|
||||
Spacer()
|
||||
Image(systemName: showRead ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(Palette.tertiaryText)
|
||||
.padding(.horizontal, 16).padding(.vertical, 16)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.plainBlackRow()
|
||||
|
||||
if showRead {
|
||||
ForEach(readItems) { story in
|
||||
NavigationLink(value: story) {
|
||||
StoryRowView(story: story, isCached: cachedStoryIds.contains(story.id),
|
||||
isRead: true, compact: true)
|
||||
}
|
||||
.plainBlackRow()
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button { markUnread(story) } label: {
|
||||
Label("Unread", systemImage: "circle")
|
||||
}
|
||||
.tint(Color(hex: "3A5A7A"))
|
||||
Button { toggleSave(story) } label: {
|
||||
Label(isSaved(story) ? "Saved" : "Save",
|
||||
systemImage: isSaved(story) ? "bookmark.fill" : "bookmark")
|
||||
}
|
||||
.tint(Palette.healthActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 12) {
|
||||
if isSyncing {
|
||||
// First sync hasn't completed yet, or a fetch is actively in flight.
|
||||
ProgressView().tint(Palette.orange)
|
||||
Text("Polling signals…")
|
||||
.font(.system(size: 14, weight: .heavy))
|
||||
.foregroundStyle(Color(hex: "AAAAAA"))
|
||||
hostLine
|
||||
} else if let error = store.error {
|
||||
// Reached the empty state because the fetch failed.
|
||||
emptyIcon("exclamationmark.triangle", color: Color(hex: "C25555"))
|
||||
Text("Couldn't reach the server")
|
||||
.font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
|
||||
Text(error.errorDescription ?? "Unknown error")
|
||||
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
||||
.multilineTextAlignment(.center)
|
||||
hostLine
|
||||
refreshButton("Retry")
|
||||
} else if !ws.connectionState.isLive {
|
||||
emptyIcon("wifi.slash")
|
||||
Text("Offline").font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
|
||||
Text("No cached stories to show")
|
||||
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
||||
refreshButton("Retry")
|
||||
} else if selectedPill != .all {
|
||||
emptyIcon("line.3.horizontal.decrease.circle")
|
||||
Text("No \(selectedPill.label) stories yet")
|
||||
.font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
|
||||
if store.isLoadingMore || store.hasMore {
|
||||
Text("Pulling more to find them…")
|
||||
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
||||
ProgressView().tint(Palette.orange)
|
||||
} else {
|
||||
Text("Nothing in the feed matched")
|
||||
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
||||
refreshButton("Refresh")
|
||||
}
|
||||
} else {
|
||||
// Connected, loaded, genuinely nothing yet.
|
||||
emptyIcon("antenna.radiowaves.left.and.right")
|
||||
Text("No signals yet").font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
|
||||
Text("Pull to refresh")
|
||||
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
||||
refreshButton("Refresh")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.top, 80)
|
||||
}
|
||||
|
||||
private func emptyIcon(_ name: String, color: Color = Palette.mutedText) -> some View {
|
||||
Image(systemName: name).font(.system(size: 30)).foregroundStyle(color)
|
||||
}
|
||||
|
||||
@ViewBuilder private var hostLine: some View {
|
||||
if let host = settings.host {
|
||||
Text(host)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(Palette.tertiaryText)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshButton(_ title: String) -> some View {
|
||||
Button {
|
||||
Task { await store.loadFeedFull() }
|
||||
} label: {
|
||||
Text(title)
|
||||
.font(.system(size: 14, weight: .bold)).foregroundStyle(.black)
|
||||
.padding(.horizontal, 22).frame(height: 44)
|
||||
.background(Palette.orange).clipShape(Capsule())
|
||||
}
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
// MARK: - Swipe actions
|
||||
|
||||
private func isSaved(_ s: StorySummary) -> Bool { savedStoryIds.contains(s.id) }
|
||||
|
||||
/// Swipe left → toggle Save (bookmarks the story to the Saved tab).
|
||||
private func toggleSave(_ s: StorySummary) {
|
||||
let id = s.id
|
||||
if let row = (try? modelContext.fetch(
|
||||
FetchDescriptor<SavedStory>(predicate: #Predicate { $0.id == id })))?.first {
|
||||
modelContext.delete(row)
|
||||
} else {
|
||||
cacheStories([s]) // keep metadata so it renders in Saved
|
||||
modelContext.insert(SavedStory(id: id))
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
private func markRead(_ s: StorySummary) {
|
||||
let id = s.id
|
||||
let existing = try? modelContext.fetch(
|
||||
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id }))
|
||||
if existing?.first == nil {
|
||||
modelContext.insert(ReadStory(id: id))
|
||||
try? modelContext.save()
|
||||
Task { try? await APIClient.shared.markStoryRead(id: id) }
|
||||
withAnimation(.snappy) { showRead = true }
|
||||
}
|
||||
}
|
||||
|
||||
private func markAllRead() async {
|
||||
let ids = mainStories.map(\.id).filter { !readStoryIds.contains($0) }
|
||||
guard !ids.isEmpty else {
|
||||
withAnimation { markAllToast = "No unread \(selectedPill.label) stories right now." }
|
||||
return
|
||||
}
|
||||
markAllPending = true
|
||||
|
||||
// Optimistic: insert ReadStory for every ID being marked.
|
||||
for id in ids {
|
||||
let exists = (try? modelContext.fetch(
|
||||
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first
|
||||
if exists == nil { modelContext.insert(ReadStory(id: id)) }
|
||||
}
|
||||
try? modelContext.save()
|
||||
withAnimation(.snappy) {
|
||||
showRead = true
|
||||
markAllToast = "Marked \(ids.count) \(ids.count == 1 ? "story" : "stories") as read."
|
||||
}
|
||||
|
||||
do {
|
||||
try await APIClient.shared.markAllRead(storyIds: ids, pill: selectedPill.label)
|
||||
} catch {
|
||||
// Rollback optimistic state.
|
||||
for id in ids {
|
||||
if let row = (try? modelContext.fetch(
|
||||
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
||||
modelContext.delete(row)
|
||||
}
|
||||
}
|
||||
try? modelContext.save()
|
||||
withAnimation {
|
||||
markAllToast = "Sync failed. Stories may reappear until retry."
|
||||
}
|
||||
}
|
||||
markAllPending = false
|
||||
}
|
||||
|
||||
private func shareItems(_ s: StorySummary) -> [Any] {
|
||||
var items: [Any] = ["\(s.headline)\n\n\(s.summary)\n\nvia Jarvis"]
|
||||
if let first = s.sources.first, let url = URL(string: first.url) {
|
||||
items.append(url)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// MARK: - Caching
|
||||
|
||||
private func cacheStories(_ stories: [StorySummary]) {
|
||||
guard !stories.isEmpty else { return }
|
||||
let container = modelContext.container
|
||||
Task.detached {
|
||||
let ctx = ModelContext(container)
|
||||
let existing = (try? ctx.fetch(FetchDescriptor<CachedStory>())) ?? []
|
||||
var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
for summary in stories {
|
||||
if let found = byId[summary.id] {
|
||||
found.signalScore = summary.signalScore
|
||||
found.sourceCount = summary.sourceCount
|
||||
found.tags = summary.tags ?? []
|
||||
found.updatedAt = summary.updatedAt
|
||||
} else {
|
||||
let c = CachedStory(from: summary)
|
||||
ctx.insert(c)
|
||||
byId[summary.id] = c
|
||||
}
|
||||
}
|
||||
try? ctx.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session-seen ids in a reference type — mutating it doesn't trigger SwiftUI
|
||||
/// updates, so marking rows seen on scroll stays cheap.
|
||||
private final class SeenTracker {
|
||||
var ids = Set<String>()
|
||||
}
|
||||
|
||||
// Map a cached story back into a StorySummary for offline rendering.
|
||||
extension StorySummary {
|
||||
init(cached: CachedStory) {
|
||||
self.init(
|
||||
id: cached.id,
|
||||
headline: cached.headline,
|
||||
summary: cached.summary,
|
||||
topic: cached.topic,
|
||||
tags: cached.tags,
|
||||
signalScore: cached.signalScore,
|
||||
scoreBreakdown: ScoreBreakdown(sourceAuthority: 0, freshness: 0, localRelevance: 0,
|
||||
crossSourceConfirmation: 0, topicImportance: 0),
|
||||
sourceCount: cached.sourceCount,
|
||||
sources: [],
|
||||
consensus: cached.consensus,
|
||||
conflict: cached.conflict,
|
||||
updatedAt: cached.updatedAt,
|
||||
createdAt: cached.createdAt,
|
||||
firstSeenAt: cached.firstSeenAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
/// Full-bleed black List row with no separator — keeps the flat feed look.
|
||||
func plainBlackRow() -> some View {
|
||||
self.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Palette.background)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user