- East Africa pill: merges Uganda + east-africa tags; Uganda pinned first via sub-sections - Southern Africa pill: covers SA, Namibia, Botswana, Zimbabwe, Zambia, Mozambique; SA/Namibia/Botswana prioritized - StoryStore.setPill now resets lastSyncedAt to bypass 30s rate-limit on filter switch - sectionSupplement background fetch populates sparse regional sections in All digest - SignalFeedView digestContent: flat hero+scroll path for pills without subSections (was showing max 5) - Theme: removed standalone Uganda pill, wired new sub-section layouts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
492 lines
17 KiB
Swift
492 lines
17 KiB
Swift
// ArticleReaderView.swift
|
|
// Jarvis — full article reader. Caches to SwiftData on load; reads from cache offline.
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
// MARK: - Scroll progress tracking
|
|
|
|
private struct ContentFrameKey: PreferenceKey {
|
|
static var defaultValue: CGRect = .zero
|
|
static func reduce(value: inout CGRect, nextValue: () -> CGRect) { value = nextValue() }
|
|
}
|
|
|
|
// MARK: - ViewModel
|
|
|
|
@MainActor
|
|
final class ArticleReaderViewModel: ObservableObject {
|
|
@Published var article: Article?
|
|
@Published var siblings: [TimelineEntry] = []
|
|
@Published var isLoading = false
|
|
@Published var error: String?
|
|
|
|
private let api = APIClient.shared
|
|
|
|
func load(route: ArticleRoute, context: ModelContext) async {
|
|
isLoading = true
|
|
error = nil
|
|
do {
|
|
let art = try await api.fetchArticle(id: route.articleId)
|
|
article = art
|
|
cache(art, context: context)
|
|
if let detail = try? await api.fetchStory(id: route.storyId) {
|
|
siblings = detail.timeline
|
|
.filter { $0.articleId != route.articleId }
|
|
.sorted { $0.publishedAt < $1.publishedAt }
|
|
}
|
|
} catch {
|
|
loadCached(route: route, context: context)
|
|
if article == nil {
|
|
self.error = (error as? APIError)?.errorDescription ?? error.localizedDescription
|
|
}
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
private func cache(_ art: Article, context: ModelContext) {
|
|
let id = art.id
|
|
let existing = try? context.fetch(
|
|
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id }))
|
|
if existing?.first == nil {
|
|
context.insert(CachedArticle(from: art))
|
|
try? context.save()
|
|
}
|
|
}
|
|
|
|
private func loadCached(route: ArticleRoute, context: ModelContext) {
|
|
let id = route.articleId
|
|
if let cached = (try? context.fetch(
|
|
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id })))?.first {
|
|
article = Article(cached: cached)
|
|
}
|
|
let sid = route.storyId
|
|
let arts = (try? context.fetch(
|
|
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.storyId == sid }))) ?? []
|
|
siblings = arts
|
|
.filter { $0.id != route.articleId }
|
|
.map { TimelineEntry(articleId: $0.id, source: $0.source, headline: $0.headline,
|
|
publishedAt: $0.publishedAt, isBreaking: false) }
|
|
}
|
|
}
|
|
|
|
// MARK: - View
|
|
|
|
struct ArticleReaderView: View {
|
|
let route: ArticleRoute
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.modelContext) private var modelContext
|
|
@Environment(\.colorScheme) private var scheme
|
|
@StateObject private var vm = ArticleReaderViewModel()
|
|
@Query private var cachedArticles: [CachedArticle]
|
|
@Query private var readStories: [ReadStory]
|
|
@Query private var savedStories: [SavedStory]
|
|
@AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .dark
|
|
@State private var showShare = false
|
|
@State private var readProgress: Double = 0
|
|
|
|
// Adaptive palette
|
|
private var bg: Color { scheme == .dark ? Color(hex: "0A0A0A") : Color(hex: "F8F7F4") }
|
|
private var bodyFg: Color { scheme == .dark ? Color(hex: "CECECE") : Color(hex: "1A1916") }
|
|
private var headFg: Color { scheme == .dark ? Color(hex: "F2F2F2") : Color(hex: "0F0E0C") }
|
|
private var mutedFg: Color { scheme == .dark ? Color(hex: "616161") : Color(hex: "888882") }
|
|
private var hairline: Color { scheme == .dark ? Color(hex: "1F1F1F") : Color(hex: "E2E0D8") }
|
|
private var skelFg: Color { scheme == .dark ? Color(hex: "181818") : Color(hex: "E8E7E3") }
|
|
|
|
private var isCached: Bool { cachedArticles.contains { $0.id == route.articleId } }
|
|
private var isRead: Bool { readStories.contains { $0.id == route.storyId } }
|
|
private var isSaved: Bool { savedStories.contains { $0.id == route.storyId } }
|
|
|
|
private var backTitle: String {
|
|
let h = route.parentHeadline
|
|
return h.count > 28 ? String(h.prefix(28)).trimmingCharacters(in: .whitespaces) + "…" : h
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
bg.ignoresSafeArea()
|
|
VStack(spacing: 0) {
|
|
progressBar
|
|
scrollContent
|
|
}
|
|
}
|
|
.navigationBarBackButtonHidden(true)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) { backButton }
|
|
ToolbarItemGroup(placement: .topBarTrailing) { trailingButtons }
|
|
}
|
|
.toolbarBackground(bg, for: .navigationBar)
|
|
.toolbarBackground(.visible, for: .navigationBar)
|
|
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
|
|
.task { await vm.load(route: route, context: modelContext) }
|
|
}
|
|
|
|
// MARK: - Progress bar
|
|
|
|
private var progressBar: some View {
|
|
ZStack(alignment: .leading) {
|
|
Rectangle().fill(hairline).frame(height: 2)
|
|
Rectangle()
|
|
.fill(Palette.orange)
|
|
.frame(width: UIScreen.main.bounds.width * readProgress, height: 2)
|
|
.animation(.linear(duration: 0.08), value: readProgress)
|
|
}
|
|
}
|
|
|
|
// MARK: - Scroll content
|
|
|
|
private var scrollContent: some View {
|
|
ScrollView {
|
|
VStack(spacing: 0) {
|
|
if let article = vm.article {
|
|
articleBody(article)
|
|
} else if vm.isLoading {
|
|
skeletonBody
|
|
} else {
|
|
errorBody
|
|
}
|
|
}
|
|
.padding(.bottom, 56)
|
|
.background(
|
|
GeometryReader { geo in
|
|
Color.clear.preference(
|
|
key: ContentFrameKey.self,
|
|
value: geo.frame(in: .named("reader"))
|
|
)
|
|
}
|
|
)
|
|
}
|
|
.coordinateSpace(name: "reader")
|
|
.onPreferenceChange(ContentFrameKey.self) { frame in
|
|
let viewH = UIScreen.main.bounds.height
|
|
let scrollable = frame.height - viewH
|
|
guard scrollable > 0 else { readProgress = 0; return }
|
|
readProgress = Double(min(1, max(0, -frame.minY / scrollable)))
|
|
}
|
|
}
|
|
|
|
// MARK: - Article layout
|
|
|
|
@ViewBuilder
|
|
private func articleBody(_ article: Article) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
|
|
// Source + meta
|
|
HStack(spacing: 10) {
|
|
Text(article.source)
|
|
.font(.system(size: 11, weight: .bold))
|
|
.foregroundStyle(Palette.primaryText)
|
|
.lineLimit(1)
|
|
.padding(.horizontal, 10).padding(.vertical, 5)
|
|
.background(Palette.orange)
|
|
.clipShape(Capsule())
|
|
|
|
Text(article.publishedAt.clockShort)
|
|
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(mutedFg)
|
|
|
|
if isCached { cachedBadge }
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 22)
|
|
.padding(.top, 24)
|
|
.padding(.bottom, 20)
|
|
|
|
// Headline
|
|
Text(article.headline)
|
|
.font(.system(size: 30, weight: .heavy))
|
|
.foregroundStyle(headFg)
|
|
.lineSpacing(2)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.padding(.horizontal, 22)
|
|
|
|
// Hero image
|
|
heroImage(article.imageUrl)
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 22)
|
|
|
|
// Author
|
|
if let author = article.author, !author.isEmpty {
|
|
Text(author.hasPrefix("By ") ? author : "By \(author)")
|
|
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
|
.foregroundStyle(mutedFg)
|
|
.padding(.horizontal, 22)
|
|
.padding(.top, 18)
|
|
}
|
|
|
|
// Section break
|
|
Rectangle()
|
|
.fill(hairline)
|
|
.frame(height: 1)
|
|
.padding(.horizontal, 22)
|
|
.padding(.top, 26)
|
|
.padding(.bottom, 26)
|
|
|
|
// Body — 17pt, generous line-height, readable contrast
|
|
Text(article.body)
|
|
.font(.system(size: 17, weight: .regular))
|
|
.foregroundStyle(bodyFg)
|
|
.lineSpacing(8.5)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: 680, alignment: .leading)
|
|
.padding(.horizontal, 22)
|
|
|
|
// Cluster
|
|
if !vm.siblings.isEmpty {
|
|
Rectangle()
|
|
.fill(hairline)
|
|
.frame(height: 1)
|
|
.padding(.horizontal, 22)
|
|
.padding(.top, 44)
|
|
.padding(.bottom, 24)
|
|
clusterSection
|
|
.padding(.horizontal, 22)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Hero image
|
|
|
|
@ViewBuilder
|
|
private func heroImage(_ urlString: String?) -> some View {
|
|
if let urlString, let url = URL(string: urlString) {
|
|
AsyncImage(url: url) { phase in
|
|
switch phase {
|
|
case .success(let img):
|
|
img.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
.transition(.opacity.animation(.easeIn(duration: 0.3)))
|
|
case .empty:
|
|
PulsingSkeleton(color: skelFg)
|
|
case .failure:
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(skelFg)
|
|
.overlay(
|
|
Image(systemName: "photo")
|
|
.font(.system(size: 24))
|
|
.foregroundStyle(mutedFg.opacity(0.5))
|
|
)
|
|
@unknown default: EmptyView()
|
|
}
|
|
}
|
|
.frame(height: 220)
|
|
.frame(maxWidth: .infinity)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|
|
|
|
// MARK: - Cached badge
|
|
|
|
private var cachedBadge: some View {
|
|
HStack(spacing: 4) {
|
|
Circle().fill(Color(hex: "4A9E4A")).frame(width: 5, height: 5)
|
|
Text("Cached")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(Color(hex: "4A9E4A"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Skeleton
|
|
|
|
private var skeletonBody: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
PulsingSkeleton(color: skelFg)
|
|
.frame(width: 130, height: 26).clipShape(Capsule())
|
|
.padding(.horizontal, 22).padding(.top, 24)
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
PulsingSkeleton(color: skelFg).frame(height: 30)
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
PulsingSkeleton(color: skelFg).frame(width: 260, height: 30)
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
}
|
|
.padding(.horizontal, 22).padding(.top, 16)
|
|
|
|
PulsingSkeleton(color: skelFg)
|
|
.frame(height: 220).clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.padding(.horizontal, 16).padding(.top, 22)
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
ForEach(0..<5, id: \.self) { i in
|
|
PulsingSkeleton(color: skelFg)
|
|
.frame(width: i == 4 ? 180 : .infinity, height: 14)
|
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
|
}
|
|
}
|
|
.padding(.horizontal, 22).padding(.top, 32)
|
|
}
|
|
}
|
|
|
|
// MARK: - Error
|
|
|
|
private var errorBody: some View {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.font(.system(size: 30))
|
|
.foregroundStyle(mutedFg)
|
|
Text(vm.error ?? "Article unavailable.")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(mutedFg)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 80)
|
|
.padding(.horizontal, 40)
|
|
}
|
|
|
|
// MARK: - Cluster section
|
|
|
|
private var clusterSection: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text("MORE FROM THIS STORY")
|
|
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
|
.kerning(0.7)
|
|
.foregroundStyle(mutedFg)
|
|
.padding(.bottom, 16)
|
|
|
|
ForEach(vm.siblings.prefix(3)) { entry in
|
|
NavigationLink(value: ArticleRoute(
|
|
articleId: entry.articleId,
|
|
storyId: route.storyId,
|
|
parentHeadline: route.parentHeadline
|
|
)) {
|
|
clusterRow(entry)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func clusterRow(_ entry: TimelineEntry) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Text(entry.source)
|
|
.font(.system(size: 12, weight: .bold))
|
|
.foregroundStyle(Palette.orange)
|
|
Spacer()
|
|
Text(entry.publishedAt.clockShort)
|
|
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(mutedFg)
|
|
}
|
|
Text(entry.headline)
|
|
.font(.system(size: 15, weight: .regular))
|
|
.foregroundStyle(bodyFg)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.multilineTextAlignment(.leading)
|
|
}
|
|
.padding(.vertical, 14)
|
|
.overlay(alignment: .bottom) {
|
|
Rectangle().fill(hairline).frame(height: 0.5)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
// MARK: - Toolbar
|
|
|
|
private var backButton: some View {
|
|
Button { dismiss() } label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "chevron.left").font(.system(size: 14, weight: .semibold))
|
|
Text(backTitle).font(.system(size: 15, weight: .regular)).lineLimit(1)
|
|
}
|
|
.foregroundStyle(Palette.orange)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var trailingButtons: some View {
|
|
Button { showShare = true } label: {
|
|
Image(systemName: "square.and.arrow.up")
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundStyle(mutedFg)
|
|
}
|
|
Button { toggleSave() } label: {
|
|
Image(systemName: isSaved ? "bookmark.fill" : "bookmark")
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundStyle(isSaved ? Palette.orange : mutedFg)
|
|
}
|
|
Button { toggleRead() } label: {
|
|
Image(systemName: isRead ? "checkmark.circle.fill" : "checkmark.circle")
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundStyle(isRead ? Palette.orange : mutedFg)
|
|
}
|
|
Menu {
|
|
Picker("Appearance", selection: $appearanceMode) {
|
|
ForEach(AppearanceMode.allCases, id: \.self) { mode in
|
|
Label(mode.label, systemImage: mode.icon).tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.inline)
|
|
} label: {
|
|
Image(systemName: appearanceMode.icon)
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundStyle(mutedFg)
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func toggleRead() {
|
|
let id = route.storyId
|
|
if let row = (try? modelContext.fetch(
|
|
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
|
modelContext.delete(row)
|
|
} else {
|
|
modelContext.insert(ReadStory(id: id))
|
|
}
|
|
try? modelContext.save()
|
|
}
|
|
|
|
private func toggleSave() {
|
|
let id = route.storyId
|
|
if let row = (try? modelContext.fetch(
|
|
FetchDescriptor<SavedStory>(predicate: #Predicate { $0.id == id })))?.first {
|
|
modelContext.delete(row)
|
|
} else {
|
|
modelContext.insert(SavedStory(id: id))
|
|
}
|
|
try? modelContext.save()
|
|
}
|
|
|
|
private var shareContent: [Any] {
|
|
guard let art = vm.article else { return [] }
|
|
var items: [Any] = ["\(art.headline)\n\nvia Jarvis"]
|
|
if !art.sourceUrl.isEmpty, let url = URL(string: art.sourceUrl) {
|
|
items.append(url)
|
|
}
|
|
return items
|
|
}
|
|
}
|
|
|
|
// MARK: - Pulsing skeleton primitive
|
|
|
|
private struct PulsingSkeleton: View {
|
|
let color: Color
|
|
@State private var on = false
|
|
|
|
var body: some View {
|
|
color.opacity(on ? 0.9 : 0.45)
|
|
.onAppear {
|
|
withAnimation(.easeInOut(duration: 0.85).repeatForever()) { on = true }
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Offline Article reconstruction
|
|
|
|
extension Article {
|
|
init(cached: CachedArticle) {
|
|
self.init(
|
|
id: cached.id,
|
|
storyId: cached.storyId,
|
|
source: cached.source,
|
|
sourceUrl: "",
|
|
headline: cached.headline,
|
|
body: cached.body,
|
|
imageUrl: cached.imageUrl,
|
|
author: cached.author,
|
|
publishedAt: cached.publishedAt
|
|
)
|
|
}
|
|
}
|