Turn "All" into a sectioned news front page
Instead of one long firehose, "All" now renders Apple/Google-News-style: a Top Stories lead block, then category sections (World, Politics, Business, AI, Technology, Africa, Sport, Science, Entertainment, Health, Lifestyle) with the top few stories each and a "See all" jump to the matching pill. Sections are built from backend tags[]; specific pills keep the flat list. "All" preloads up to ~100 stories to fill sections. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,27 @@ struct SignalFeedView: View {
|
||||
filteredStories.filter { readStoryIds.contains($0.id) }
|
||||
}
|
||||
|
||||
/// "All" shows a sectioned front page; specific pills show a flat list.
|
||||
private var isDigest: Bool { selectedPill == .all }
|
||||
|
||||
/// Front-page digest: Top Stories + up to 3 per category section, deduped.
|
||||
private var digest: (top: [StorySummary], sections: [(NewsSection, [StorySummary])]) {
|
||||
let unread = filteredStories.filter { !readStoryIds.contains($0.id) }
|
||||
let top = Array(unread.prefix(5))
|
||||
var pool = Array(unread.dropFirst(5))
|
||||
var out: [(NewsSection, [StorySummary])] = []
|
||||
for section in NewsSection.sections {
|
||||
var picked: [StorySummary] = [], rest: [StorySummary] = []
|
||||
for s in pool {
|
||||
if picked.count < 3 && s.inSection(section) { picked.append(s) }
|
||||
else { rest.append(s) }
|
||||
}
|
||||
pool = rest
|
||||
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)
|
||||
@@ -99,7 +120,7 @@ struct SignalFeedView: View {
|
||||
header
|
||||
syncBar
|
||||
topicPills
|
||||
columnHeaders
|
||||
if !isDigest { columnHeaders } // sections carry their own headers
|
||||
Divider().overlay(Palette.hairline)
|
||||
feedList
|
||||
}
|
||||
@@ -127,13 +148,17 @@ struct SignalFeedView: View {
|
||||
}
|
||||
.task { CacheMaintenance.prune(modelContext) } // bound the caches on launch
|
||||
.task(id: selectedPill) {
|
||||
// A filter active but nothing matched in the loaded set → pull a few
|
||||
// more pages so sparse pills still populate.
|
||||
guard selectedPill != .all else { return }
|
||||
var pages = 0
|
||||
while filteredStories.isEmpty && store.hasMore && pages < 8 {
|
||||
await store.loadMore()
|
||||
pages += 1
|
||||
if selectedPill == .all {
|
||||
// Front page: pull enough stories to fill the sections.
|
||||
while store.stories.count < 100 && store.hasMore && pages < 5 {
|
||||
await store.loadMore(); pages += 1
|
||||
}
|
||||
} else {
|
||||
// Sparse pill: pull a few more pages until something matches.
|
||||
while filteredStories.isEmpty && store.hasMore && pages < 8 {
|
||||
await store.loadMore(); pages += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,18 +260,10 @@ struct SignalFeedView: View {
|
||||
List {
|
||||
if mainStories.isEmpty && readItems.isEmpty {
|
||||
emptyState.plainBlackRow()
|
||||
} else if isDigest {
|
||||
digestContent
|
||||
} else {
|
||||
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
|
||||
flatContent
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
@@ -260,6 +277,51 @@ struct SignalFeedView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat list for a specific pill.
|
||||
@ViewBuilder private var flatContent: some View {
|
||||
ForEach(mainStories) { mainRow($0) }
|
||||
if mainStories.isEmpty { caughtUpRow.plainBlackRow() }
|
||||
if store.isLoadingMore {
|
||||
ProgressView().tint(Palette.orange)
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 20).plainBlackRow()
|
||||
}
|
||||
readShelf
|
||||
}
|
||||
|
||||
/// Sectioned front page for "All".
|
||||
@ViewBuilder private var digestContent: some View {
|
||||
let d = digest
|
||||
sectionHeader("TOP STORIES", pill: nil)
|
||||
ForEach(d.top) { mainRow($0) }
|
||||
ForEach(d.sections, id: \.0.id) { section, stories in
|
||||
sectionHeader(section.label.uppercased(), pill: section.pill)
|
||||
ForEach(stories) { mainRow($0) }
|
||||
}
|
||||
readShelf
|
||||
}
|
||||
|
||||
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(.white)
|
||||
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) -> some View {
|
||||
NavigationLink(value: story) {
|
||||
|
||||
Reference in New Issue
Block a user