Turn "All" into a sectioned news front page
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

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:
Robin Kutesa
2026-06-20 18:34:14 +03:00
parent 22b859dc7b
commit da43399ead
2 changed files with 111 additions and 18 deletions

View File

@@ -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) {