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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
|
||||||
} else {
|
|
||||||
// Other pills (incl. Tech/Finance) exclude F1 (backend mis-tags it).
|
// Other pills (incl. Tech/Finance) exclude F1 (backend mis-tags it).
|
||||||
filtered = base.filter { interest.matches($0) && !Interest.f1.matches($0) }
|
return base.filter { interest.matches($0) && !Interest.f1.matches($0) }
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// "All" is news-only: F1 lives solely under its own pill.
|
// "All" is news-only: F1 lives solely under its own pill.
|
||||||
filtered = base.filter { !Interest.f1.matches($0) }
|
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,64 +236,120 @@ 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
|
||||||
NavigationLink(value: story) {
|
mainRow(story)
|
||||||
StoryRowView(story: story,
|
|
||||||
isCached: cachedStoryIds.contains(story.id),
|
|
||||||
isRead: readStoryIds.contains(story.id))
|
|
||||||
}
|
}
|
||||||
.listRowInsets(EdgeInsets())
|
if mainStories.isEmpty {
|
||||||
.listRowBackground(Color.black)
|
caughtUpRow.plainBlackRow()
|
||||||
.listRowSeparator(.hidden)
|
}
|
||||||
|
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 {
|
.onAppear {
|
||||||
markSeen(story)
|
markSeen(story)
|
||||||
if story.id == displayStories.last?.id {
|
if story.id == mainStories.last?.id { Task { await store.loadMore() } }
|
||||||
Task { await store.loadMore() }
|
|
||||||
}
|
}
|
||||||
}
|
// Swipe left: short swipe reveals Save (tap); a full/long swipe marks read.
|
||||||
// Swipe left → Save · swipe right → Share + mark read
|
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
Button { markRead(story) } label: {
|
||||||
|
Label("Mark read", systemImage: "checkmark.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)
|
||||||
}
|
}
|
||||||
|
// Swipe right → Share.
|
||||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||||
Button {
|
Button { markRead(story); shareStory = story } label: {
|
||||||
markRead(story)
|
|
||||||
shareStory = story
|
|
||||||
} label: {
|
|
||||||
Label("Share", systemImage: "square.and.arrow.up")
|
Label("Share", systemImage: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
.tint(Palette.orange)
|
.tint(Palette.orange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if store.isLoadingMore {
|
private var caughtUpRow: some View {
|
||||||
ProgressView()
|
VStack(spacing: 10) {
|
||||||
.tint(Palette.orange)
|
Image(systemName: "checkmark.circle")
|
||||||
.frame(maxWidth: .infinity)
|
.font(.system(size: 32)).foregroundStyle(Color(hex: "3E7E3E"))
|
||||||
.padding(.vertical, 20)
|
Text("You're all caught up").font(.system(size: 16, weight: .heavy)).foregroundStyle(.white)
|
||||||
.listRowInsets(EdgeInsets())
|
Text("Pull to refresh for more")
|
||||||
.listRowBackground(Color.black)
|
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
||||||
.listRowSeparator(.hidden)
|
}
|
||||||
|
.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: 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.background(Color.black)
|
|
||||||
.onAppear { captureSeenSnapshot() }
|
|
||||||
.refreshable {
|
|
||||||
captureSeenSnapshot() // sink what you've already seen
|
|
||||||
await store.loadStories(refresh: true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user