From 4331621ccc93e822afd347296ddefa5f7f2a99aa Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Fri, 19 Jun 2026 15:07:58 +0300 Subject: [PATCH] Split save/mark-read swipe; move read stories into a collapsible shelf - Left swipe: short reveals Save (tap), full swipe marks read. Right swipe still shares. - Read stories leave the main feed and collapse into a "Read" shelf at the bottom (collapsed by default), shown as slim one-line rows; swipe to mark unread. An "all caught up" state shows when everything loaded is read. - StoryRowView gains a compact presentation for the shelf. Co-Authored-By: Claude Opus 4.8 --- Jarvis/Views/Home/SignalFeedView.swift | 209 +++++++++++++++++-------- Jarvis/Views/Home/StoryRowView.swift | 27 ++++ 2 files changed, 169 insertions(+), 67 deletions(-) diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index 1f7d6c3..550a5dd 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -33,14 +33,16 @@ struct SignalFeedView: View { /// 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 = [] + @State private var showRead = false /// Stories that have full article content cached → eligible for the green dot. private var cachedStoryIds: Set { Set(cachedArticles.map(\.storyId)) } private var readStoryIds: Set { Set(readStories.map(\.id)) } private var savedStoryIds: Set { Set(savedStories.map(\.id)) } - /// Live stories when online; cached fallback when the feed is empty & offline. - private var displayStories: [StorySummary] { + /// Live stories when online; cached fallback when offline. Interest-filtered, + /// not yet split by read state. + private var filteredStories: [StorySummary] { let base: [StorySummary] if !store.stories.isEmpty { base = store.stories.sorted(by: StorySummary.feedOrder) @@ -49,27 +51,26 @@ struct SignalFeedView: View { } else { base = [] } - - let filtered: [StorySummary] if let interest = selectedInterest { - if interest.id == "f1" { - filtered = base.filter(interest.matches) - } else { - // Other pills (incl. Tech/Finance) exclude F1 (backend mis-tags it). - filtered = base.filter { interest.matches($0) && !Interest.f1.matches($0) } - } - } else { - // "All" is news-only: F1 lives solely under its own pill. - filtered = base.filter { !Interest.f1.matches($0) } + if interest.id == "f1" { return base.filter(interest.matches) } + // Other pills (incl. Tech/Finance) exclude F1 (backend mis-tags it). + return base.filter { interest.matches($0) && !Interest.f1.matches($0) } } + // "All" is news-only: F1 lives solely under its own pill. + return base.filter { !Interest.f1.matches($0) } + } - // Sink stories you've already read or seen (in a prior load) so fresh - // content surfaces and you stop seeing the same articles. Uses a per-load - // snapshot so the order is stable while you scroll. - let demoted = readStoryIds.union(seenSnapshot) - let fresh = filtered.filter { !demoted.contains($0.id) } - let old = filtered.filter { demoted.contains($0.id) } - return fresh + old + /// The main feed: unread stories, with already-seen ones sunk below fresh ones. + private var mainStories: [StorySummary] { + let notRead = filteredStories.filter { !readStoryIds.contains($0.id) } + let fresh = notRead.filter { !seenSnapshot.contains($0.id) } + let seen = notRead.filter { seenSnapshot.contains($0.id) } + return fresh + seen + } + + /// Read stories, tucked into the collapsible "Read" shelf. + private var readItems: [StorySummary] { + filteredStories.filter { readStoryIds.contains($0.id) } } private func captureSeenSnapshot() { seenSnapshot = Set(seenStories.map(\.id)) } @@ -81,6 +82,15 @@ struct SignalFeedView: View { try? modelContext.save() } + private func markUnread(_ s: StorySummary) { + let id = s.id + if let row = (try? modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first { + modelContext.delete(row) + try? modelContext.save() + } + } + var body: some View { NavigationStack { ZStack { @@ -121,7 +131,7 @@ struct SignalFeedView: View { // more pages so sparse interests still populate. guard selectedInterest != nil else { return } var pages = 0 - while displayStories.isEmpty && store.hasMore && pages < 8 { + while filteredStories.isEmpty && store.hasMore && pages < 8 { await store.loadMore() pages += 1 } @@ -226,65 +236,121 @@ struct SignalFeedView: View { private var feedList: some View { List { - if displayStories.isEmpty { - emptyState - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.black) - .listRowSeparator(.hidden) + if mainStories.isEmpty && readItems.isEmpty { + emptyState.plainBlackRow() } else { - ForEach(displayStories) { story in + ForEach(mainStories) { story in + mainRow(story) + } + if mainStories.isEmpty { + caughtUpRow.plainBlackRow() + } + if store.isLoadingMore { + ProgressView().tint(Palette.orange) + .frame(maxWidth: .infinity).padding(.vertical, 20).plainBlackRow() + } + readShelf + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(Color.black) + .animation(.snappy, value: showRead) + .onAppear { captureSeenSnapshot() } + .refreshable { + captureSeenSnapshot() // sink what you've already seen + await store.loadStories(refresh: true) + } + } + + @ViewBuilder + private func mainRow(_ story: StorySummary) -> some View { + NavigationLink(value: story) { + StoryRowView(story: story, isCached: cachedStoryIds.contains(story.id)) + } + .plainBlackRow() + .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) + } + } + + 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: .heavy)).foregroundStyle(.white) + 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(Color(hex: "666666")) + .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: readStoryIds.contains(story.id)) + StoryRowView(story: story, isCached: cachedStoryIds.contains(story.id), + isRead: true, compact: true) } - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.black) - .listRowSeparator(.hidden) - .onAppear { - markSeen(story) - if story.id == displayStories.last?.id { - Task { await store.loadMore() } - } - } - // Swipe left → Save · swipe right → Share + mark read + .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) } - .swipeActions(edge: .leading, allowsFullSwipe: true) { - Button { - markRead(story) - shareStory = story - } label: { - Label("Share", systemImage: "square.and.arrow.up") - } - .tint(Palette.orange) - } - } - - if store.isLoadingMore { - ProgressView() - .tint(Palette.orange) - .frame(maxWidth: .infinity) - .padding(.vertical, 20) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.black) - .listRowSeparator(.hidden) } } } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .background(Color.black) - .onAppear { captureSeenSnapshot() } - .refreshable { - captureSeenSnapshot() // sink what you've already seen - await store.loadStories(refresh: true) - } } private var emptyState: some View { @@ -438,3 +504,12 @@ extension StorySummary { ) } } + +private extension View { + /// Full-bleed black List row with no separator — keeps the flat feed look. + func plainBlackRow() -> some View { + self.listRowInsets(EdgeInsets()) + .listRowBackground(Color.black) + .listRowSeparator(.hidden) + } +} diff --git a/Jarvis/Views/Home/StoryRowView.swift b/Jarvis/Views/Home/StoryRowView.swift index f23b3ea..4171f6b 100644 --- a/Jarvis/Views/Home/StoryRowView.swift +++ b/Jarvis/Views/Home/StoryRowView.swift @@ -9,10 +9,37 @@ struct StoryRowView: View { var isCached: Bool = false /// True once the story has been opened — drops the orange stripe and dims it. var isRead: Bool = false + /// Slim single-line presentation, used for the "Read" shelf. + var compact: Bool = false private var sourceNames: [String] { story.sources.map(\.name) } var body: some View { + if compact { compactBody } else { fullBody } + } + + private var compactBody: some View { + HStack(spacing: 0) { + SignalStripe(score: story.signalScore, muted: true) + Text(story.headline) + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(Color(hex: "6A6A6A")) + .lineLimit(1) + .padding(.leading, 14) + .padding(.vertical, 11) + Spacer(minLength: 12) + Text("\(story.signalScore)") + .font(.system(size: 14, weight: .heavy, design: .monospaced)) + .foregroundStyle(Color(hex: "474747")) + .padding(.trailing, 16) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.black) + .overlay(alignment: .bottom) { Rectangle().fill(Palette.hairline).frame(height: 0.5) } + .contentShape(Rectangle()) + } + + private var fullBody: some View { HStack(alignment: .top, spacing: 0) { SignalStripe(score: story.signalScore, muted: isRead)