// 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(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(predicate: #Predicate { $0.id == id })))?.first { article = Article(cached: cached) } let sid = route.storyId let arts = (try? context.fetch( FetchDescriptor(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(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(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 ) } }