Split save/mark-read swipe; move read stories into a collapsible shelf
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

- 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 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-06-19 15:07:58 +03:00
parent feee58748c
commit 4331621ccc
2 changed files with 169 additions and 67 deletions

View File

@@ -33,14 +33,16 @@ struct SignalFeedView: View {
/// Snapshot of already-seen story ids, captured per load so the order stays /// Snapshot of already-seen story ids, captured per load so the order stays
/// stable while scrolling (and refreshes to sink newly-seen ones). /// stable while scrolling (and refreshes to sink newly-seen ones).
@State private var seenSnapshot: Set<String> = [] @State private var seenSnapshot: Set<String> = []
@State private var showRead = false
/// Stories that have full article content cached eligible for the green dot. /// Stories that have full article content cached eligible for the green dot.
private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) } private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) }
private var readStoryIds: Set<String> { Set(readStories.map(\.id)) } private var readStoryIds: Set<String> { Set(readStories.map(\.id)) }
private var savedStoryIds: Set<String> { Set(savedStories.map(\.id)) } private var savedStoryIds: Set<String> { Set(savedStories.map(\.id)) }
/// Live stories when online; cached fallback when the feed is empty & offline. /// Live stories when online; cached fallback when offline. Interest-filtered,
private var displayStories: [StorySummary] { /// not yet split by read state.
private var filteredStories: [StorySummary] {
let base: [StorySummary] let base: [StorySummary]
if !store.stories.isEmpty { if !store.stories.isEmpty {
base = store.stories.sorted(by: StorySummary.feedOrder) base = store.stories.sorted(by: StorySummary.feedOrder)
@@ -49,27 +51,26 @@ struct SignalFeedView: View {
} else { } else {
base = [] base = []
} }
let filtered: [StorySummary]
if let interest = selectedInterest { if let interest = selectedInterest {
if interest.id == "f1" { if interest.id == "f1" { return base.filter(interest.matches) }
filtered = base.filter(interest.matches) // Other pills (incl. Tech/Finance) exclude F1 (backend mis-tags it).
} else { return base.filter { interest.matches($0) && !Interest.f1.matches($0) }
// 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) }
} }
// "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 /// The main feed: unread stories, with already-seen ones sunk below fresh ones.
// content surfaces and you stop seeing the same articles. Uses a per-load private var mainStories: [StorySummary] {
// snapshot so the order is stable while you scroll. let notRead = filteredStories.filter { !readStoryIds.contains($0.id) }
let demoted = readStoryIds.union(seenSnapshot) let fresh = notRead.filter { !seenSnapshot.contains($0.id) }
let fresh = filtered.filter { !demoted.contains($0.id) } let seen = notRead.filter { seenSnapshot.contains($0.id) }
let old = filtered.filter { demoted.contains($0.id) } return fresh + seen
return fresh + old }
/// 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)) } private func captureSeenSnapshot() { seenSnapshot = Set(seenStories.map(\.id)) }
@@ -81,6 +82,15 @@ struct SignalFeedView: View {
try? modelContext.save() try? modelContext.save()
} }
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()
}
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
@@ -121,7 +131,7 @@ struct SignalFeedView: View {
// more pages so sparse interests still populate. // more pages so sparse interests still populate.
guard selectedInterest != nil else { return } guard selectedInterest != nil else { return }
var pages = 0 var pages = 0
while displayStories.isEmpty && store.hasMore && pages < 8 { while filteredStories.isEmpty && store.hasMore && pages < 8 {
await store.loadMore() await store.loadMore()
pages += 1 pages += 1
} }
@@ -226,65 +236,121 @@ struct SignalFeedView: View {
private var feedList: some View { private var feedList: some View {
List { List {
if displayStories.isEmpty { if mainStories.isEmpty && readItems.isEmpty {
emptyState emptyState.plainBlackRow()
.listRowInsets(EdgeInsets())
.listRowBackground(Color.black)
.listRowSeparator(.hidden)
} else { } 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) { NavigationLink(value: story) {
StoryRowView(story: story, StoryRowView(story: story, isCached: cachedStoryIds.contains(story.id),
isCached: cachedStoryIds.contains(story.id), isRead: true, compact: true)
isRead: readStoryIds.contains(story.id))
} }
.listRowInsets(EdgeInsets()) .plainBlackRow()
.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
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { markUnread(story) } label: {
Label("Unread", systemImage: "circle")
}
.tint(Color(hex: "3A5A7A"))
Button { toggleSave(story) } label: { Button { toggleSave(story) } label: {
Label(isSaved(story) ? "Saved" : "Save", Label(isSaved(story) ? "Saved" : "Save",
systemImage: isSaved(story) ? "bookmark.fill" : "bookmark") systemImage: isSaved(story) ? "bookmark.fill" : "bookmark")
} }
.tint(Palette.healthActive) .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 { 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)
}
}

View File

@@ -9,10 +9,37 @@ struct StoryRowView: View {
var isCached: Bool = false var isCached: Bool = false
/// True once the story has been opened drops the orange stripe and dims it. /// True once the story has been opened drops the orange stripe and dims it.
var isRead: Bool = false 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) } private var sourceNames: [String] { story.sources.map(\.name) }
var body: some View { 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) { HStack(alignment: .top, spacing: 0) {
SignalStripe(score: story.signalScore, muted: isRead) SignalStripe(score: story.signalScore, muted: isRead)