Both StoryDetailView and ArticleReaderView now show three icon buttons in the navigation bar trailing area: - Share (square.and.arrow.up) — story headline+summary+URL / article headline+sourceUrl, passed to UIActivityViewController via ShareSheet. - Save (bookmark / bookmark.fill, orange when active) — toggles SavedStory in SwiftData; StoryDetailView also ensures a CachedStory exists so the Saved tab can display it offline. - Mark read/unread (checkmark.circle / checkmark.circle.fill, orange when read) — toggle mirroring the swipe action already present in the feed. The read state in StoryDetailView is still auto-marked on open (.task → markRead()); the toolbar button lets users undo that without going back to the feed. ArticleReaderView targets the parent story (route.storyId). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
307 lines
12 KiB
Swift
307 lines
12 KiB
Swift
// ArticleReaderView.swift
|
|
// Jarvis — full article. Caches to SwiftData on load; reads from cache offline.
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
@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 {
|
|
// Offline: fall back to the SwiftData cache.
|
|
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) }
|
|
}
|
|
}
|
|
|
|
struct ArticleReaderView: View {
|
|
let route: ArticleRoute
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.modelContext) private var modelContext
|
|
@StateObject private var vm = ArticleReaderViewModel()
|
|
@Query private var cachedArticles: [CachedArticle]
|
|
@Query private var readStories: [ReadStory]
|
|
@Query private var savedStories: [SavedStory]
|
|
@State private var showShare = false
|
|
|
|
private var article: Article? { vm.article }
|
|
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 > 30 ? String(h.prefix(30)).trimmingCharacters(in: .whitespaces) + "…" : h
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.black.ignoresSafeArea()
|
|
ScrollView {
|
|
if let article {
|
|
content(article)
|
|
} else if vm.isLoading {
|
|
ProgressView().tint(Palette.orange).padding(.top, 80)
|
|
} else {
|
|
Text(vm.error ?? "Article unavailable.")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(Color(hex: "555555"))
|
|
.padding(.top, 80)
|
|
}
|
|
}
|
|
}
|
|
.navigationBarBackButtonHidden(true)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) { backButton }
|
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
|
Button { showShare = true } label: {
|
|
Image(systemName: "square.and.arrow.up")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(Color(hex: "888888"))
|
|
}
|
|
Button { toggleSave() } label: {
|
|
Image(systemName: isSaved ? "bookmark.fill" : "bookmark")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(isSaved ? Palette.orange : Color(hex: "888888"))
|
|
}
|
|
Button { toggleRead() } label: {
|
|
Image(systemName: isRead ? "checkmark.circle.fill" : "checkmark.circle")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(isRead ? Palette.orange : Color(hex: "888888"))
|
|
}
|
|
}
|
|
}
|
|
.toolbarBackground(Color.black, for: .navigationBar)
|
|
.toolbarBackground(.visible, for: .navigationBar)
|
|
.preferredColorScheme(.dark)
|
|
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
|
|
.task { await vm.load(route: route, context: modelContext) }
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
private var backButton: some View {
|
|
Button { dismiss() } label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "chevron.left").font(.system(size: 15, weight: .bold))
|
|
Text(backTitle).font(.system(size: 16, weight: .regular)).lineLimit(1)
|
|
}
|
|
.foregroundStyle(Palette.orange)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func content(_ article: Article) -> some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
// Source pill + timestamp + cached badge
|
|
HStack(spacing: 10) {
|
|
Text(article.source)
|
|
.font(.system(size: 12, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 10).padding(.vertical, 5)
|
|
.background(Palette.orange)
|
|
.clipShape(Capsule())
|
|
Text(article.publishedAt.clockShort)
|
|
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(Color(hex: "666666"))
|
|
if isCached { CachedBadge(text: "Cached") }
|
|
Spacer()
|
|
}
|
|
|
|
Text(article.headline)
|
|
.font(.system(size: 26, weight: .heavy))
|
|
.foregroundStyle(.white)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
heroImage(article.imageUrl)
|
|
|
|
if let author = article.author, !author.isEmpty {
|
|
Text("By \(author)")
|
|
.font(.system(size: 13, weight: .bold))
|
|
.foregroundStyle(Color(hex: "777777"))
|
|
}
|
|
|
|
Text(article.body)
|
|
.font(.system(size: 16, weight: .regular))
|
|
.foregroundStyle(Palette.bodyText) // #4A4A4A
|
|
.lineSpacing(10) // ~1.65 line-height at 16pt
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
if !vm.siblings.isEmpty {
|
|
Divider().overlay(Palette.hairline).padding(.vertical, 8)
|
|
clusterSection
|
|
}
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 12)
|
|
}
|
|
|
|
private func heroImage(_ urlString: String?) -> some View {
|
|
let fallback = RoundedRectangle(cornerRadius: 10).fill(Palette.surface2)
|
|
return Group {
|
|
if let urlString, let url = URL(string: urlString) {
|
|
AsyncImage(url: url) { phase in
|
|
switch phase {
|
|
case .success(let image):
|
|
image.resizable().aspectRatio(contentMode: .fill)
|
|
case .empty:
|
|
fallback.overlay(ProgressView().tint(Color(hex: "555555")))
|
|
case .failure:
|
|
fallback.overlay(Image(systemName: "photo")
|
|
.font(.system(size: 26)).foregroundStyle(Color(hex: "3A3A3A")))
|
|
@unknown default:
|
|
fallback
|
|
}
|
|
}
|
|
} else {
|
|
fallback.overlay(Image(systemName: "photo")
|
|
.font(.system(size: 26)).foregroundStyle(Color(hex: "3A3A3A")))
|
|
}
|
|
}
|
|
.frame(height: 200)
|
|
.frame(maxWidth: .infinity)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
|
|
private var clusterSection: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text("MORE FROM THIS CLUSTER")
|
|
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
|
.kerning(0.8)
|
|
.foregroundStyle(Color(hex: "555555"))
|
|
.padding(.bottom, 12)
|
|
|
|
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: 5) {
|
|
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(Color(hex: "555555"))
|
|
}
|
|
Text(entry.headline)
|
|
.font(.system(size: 15, weight: .regular))
|
|
.foregroundStyle(Color(hex: "CFCFCF"))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.multilineTextAlignment(.leading)
|
|
}
|
|
.padding(.vertical, 12)
|
|
.overlay(alignment: .bottom) { Rectangle().fill(Palette.hairline).frame(height: 0.5) }
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
// Reconstruct an Article from its cached copy for offline display.
|
|
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
|
|
)
|
|
}
|
|
}
|