diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index edc0043..61c27a3 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -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()))?.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) { diff --git a/Jarvis/Views/Shared/Theme.swift b/Jarvis/Views/Shared/Theme.swift index 977bfdd..3424edc 100644 --- a/Jarvis/Views/Shared/Theme.swift +++ b/Jarvis/Views/Shared/Theme.swift @@ -224,4 +224,35 @@ extension StorySummary { func matches(_ pill: StoryPill) -> Bool { pill == .all || !effectiveTags.isDisjoint(with: pill.slugs) } + + func inSection(_ section: NewsSection) -> Bool { + !effectiveTags.isDisjoint(with: section.slugs) + } +} + +// MARK: - News sections (the "All" front page) +// +// Groups the firehose into Apple/Google-News-style sections. A story lands in +// the first section (by this order) whose slugs it carries; `pill` powers "See all". + +struct NewsSection: Identifiable { + let id: String + let label: String + let slugs: Set + let pill: StoryPill? + + static let sections: [NewsSection] = [ + .init(id: "us", label: "US", slugs: ["united-states"], pill: .unitedStates), + .init(id: "world", label: "World", slugs: ["world-news", "news", "news-and-current-affairs"], pill: nil), + .init(id: "politics", label: "Politics", slugs: ["politics", "human-rights", "investigative"], pill: nil), + .init(id: "business", label: "Business", slugs: ["business", "finance", "fintech", "cryptocurrency", "blockchain", "entrepreneur", "startups", "real-estate"], pill: nil), + .init(id: "ai", label: "AI", slugs: ["artificial-intelligence", "machine-learning", "robotics"], pill: .ai), + .init(id: "technology", label: "Technology", slugs: ["technology", "programming-and-software-development", "cybersecurity", "security", "web-design-and-ui-ux", "wordpress-and-web-development", "privacy-and-data-protection", "cloud-computing"], pill: .tech), + .init(id: "africa", label: "Africa", slugs: ["africa", "east-africa", "south-africa", "uganda"], pill: nil), + .init(id: "sport", label: "Sport", slugs: ["sports", "esports", "formula-1"], pill: .sport), + .init(id: "science", label: "Science", slugs: ["science", "astronomy", "environment", "green-technology", "sustainability"], pill: nil), + .init(id: "entertainment", label: "Entertainment", slugs: ["entertainment", "music", "pop-culture", "video-game", "arts-and-culture", "books-and-literature"], pill: nil), + .init(id: "health", label: "Health", slugs: ["health", "health-and-wellness", "mental-health"], pill: nil), + .init(id: "lifestyle", label: "Lifestyle", slugs: ["lifestyle", "food-and-drink", "travel", "gardening", "outdoor-and-hiking", "parenting-and-family", "interior-design-and-home-decor", "photography", "self-improvement-and-personal-development", "education", "e-learning"], pill: nil), + ] }