feat(reader): redesign ArticleReaderView + global appearance toggle
Reading experience:
- Body text #CECECE (dark) / #1A1916 (light) — was near-invisible #4A4A4A
- 30pt heavy headline, 17pt body at 8.5pt line-spacing
- Skeleton pulse loading state replaces dark spinner box
- Hero image fades in on load (0.3s ease-in)
- Orange reading progress bar (2pt) below nav bar
- Max reading width 680pt for iPad legibility
Appearance:
- AppearanceMode enum (system/light/dark) in Theme.swift
- @AppStorage("appearanceMode") applied once in JarvisApp root
- Appearance picker (Picker in Menu) in reader toolbar
- Removed .preferredColorScheme(.dark) from all 12 child views
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
@main
|
@main
|
||||||
struct JarvisApp: App {
|
struct JarvisApp: App {
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
@AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .dark
|
||||||
@StateObject private var settings = ServerSettings.shared
|
@StateObject private var settings = ServerSettings.shared
|
||||||
@StateObject private var store = StoryStore.shared
|
@StateObject private var store = StoryStore.shared
|
||||||
@StateObject private var ws = WebSocketManager.shared
|
@StateObject private var ws = WebSocketManager.shared
|
||||||
@@ -39,6 +40,7 @@ struct JarvisApp: App {
|
|||||||
OnboardingView()
|
OnboardingView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.preferredColorScheme(appearanceMode.colorScheme)
|
||||||
.environmentObject(settings)
|
.environmentObject(settings)
|
||||||
.environmentObject(store)
|
.environmentObject(store)
|
||||||
.environmentObject(ws)
|
.environmentObject(ws)
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ struct ConnectivityView: View {
|
|||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
.task { await recheck() }
|
.task { await recheck() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ struct AddFeedSheet: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
.presentationDetents([.medium])
|
.presentationDetents([.medium])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ struct FeedManagerView: View {
|
|||||||
feedList
|
feedList
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
.sheet(isPresented: $showAdd) {
|
.sheet(isPresented: $showAdd) {
|
||||||
AddFeedSheet { url, name in
|
AddFeedSheet { url, name in
|
||||||
|
|||||||
@@ -147,7 +147,6 @@ struct SignalFeedView: View {
|
|||||||
ShareSheet(items: shareItems(story))
|
ShareSheet(items: shareItems(story))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
.onChange(of: store.stories) { _, newValue in
|
.onChange(of: store.stories) { _, newValue in
|
||||||
cacheStories(newValue)
|
cacheStories(newValue)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,5 +125,4 @@ struct StoryRowView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
// ArticleReaderView.swift
|
// ArticleReaderView.swift
|
||||||
// Jarvis — full article. Caches to SwiftData on load; reads from cache offline.
|
// Jarvis — full article reader. Caches to SwiftData on load; reads from cache offline.
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
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
|
@MainActor
|
||||||
final class ArticleReaderViewModel: ObservableObject {
|
final class ArticleReaderViewModel: ObservableObject {
|
||||||
@Published var article: Article?
|
@Published var article: Article?
|
||||||
@@ -20,14 +29,12 @@ final class ArticleReaderViewModel: ObservableObject {
|
|||||||
let art = try await api.fetchArticle(id: route.articleId)
|
let art = try await api.fetchArticle(id: route.articleId)
|
||||||
article = art
|
article = art
|
||||||
cache(art, context: context)
|
cache(art, context: context)
|
||||||
|
|
||||||
if let detail = try? await api.fetchStory(id: route.storyId) {
|
if let detail = try? await api.fetchStory(id: route.storyId) {
|
||||||
siblings = detail.timeline
|
siblings = detail.timeline
|
||||||
.filter { $0.articleId != route.articleId }
|
.filter { $0.articleId != route.articleId }
|
||||||
.sorted { $0.publishedAt < $1.publishedAt }
|
.sorted { $0.publishedAt < $1.publishedAt }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Offline: fall back to the SwiftData cache.
|
|
||||||
loadCached(route: route, context: context)
|
loadCached(route: route, context: context)
|
||||||
if article == nil {
|
if article == nil {
|
||||||
self.error = (error as? APIError)?.errorDescription ?? error.localizedDescription
|
self.error = (error as? APIError)?.errorDescription ?? error.localizedDescription
|
||||||
@@ -39,8 +46,7 @@ final class ArticleReaderViewModel: ObservableObject {
|
|||||||
private func cache(_ art: Article, context: ModelContext) {
|
private func cache(_ art: Article, context: ModelContext) {
|
||||||
let id = art.id
|
let id = art.id
|
||||||
let existing = try? context.fetch(
|
let existing = try? context.fetch(
|
||||||
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id })
|
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id }))
|
||||||
)
|
|
||||||
if existing?.first == nil {
|
if existing?.first == nil {
|
||||||
context.insert(CachedArticle(from: art))
|
context.insert(CachedArticle(from: art))
|
||||||
try? context.save()
|
try? context.save()
|
||||||
@@ -50,14 +56,12 @@ final class ArticleReaderViewModel: ObservableObject {
|
|||||||
private func loadCached(route: ArticleRoute, context: ModelContext) {
|
private func loadCached(route: ArticleRoute, context: ModelContext) {
|
||||||
let id = route.articleId
|
let id = route.articleId
|
||||||
if let cached = (try? context.fetch(
|
if let cached = (try? context.fetch(
|
||||||
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id })
|
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id })))?.first {
|
||||||
))?.first {
|
|
||||||
article = Article(cached: cached)
|
article = Article(cached: cached)
|
||||||
}
|
}
|
||||||
let sid = route.storyId
|
let sid = route.storyId
|
||||||
let arts = (try? context.fetch(
|
let arts = (try? context.fetch(
|
||||||
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.storyId == sid })
|
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.storyId == sid }))) ?? []
|
||||||
)) ?? []
|
|
||||||
siblings = arts
|
siblings = arts
|
||||||
.filter { $0.id != route.articleId }
|
.filter { $0.id != route.articleId }
|
||||||
.map { TimelineEntry(articleId: $0.id, source: $0.source, headline: $0.headline,
|
.map { TimelineEntry(articleId: $0.id, source: $0.source, headline: $0.headline,
|
||||||
@@ -65,77 +69,367 @@ final class ArticleReaderViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - View
|
||||||
|
|
||||||
struct ArticleReaderView: View {
|
struct ArticleReaderView: View {
|
||||||
let route: ArticleRoute
|
let route: ArticleRoute
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.colorScheme) private var scheme
|
||||||
@StateObject private var vm = ArticleReaderViewModel()
|
@StateObject private var vm = ArticleReaderViewModel()
|
||||||
@Query private var cachedArticles: [CachedArticle]
|
@Query private var cachedArticles: [CachedArticle]
|
||||||
@Query private var readStories: [ReadStory]
|
@Query private var readStories: [ReadStory]
|
||||||
@Query private var savedStories: [SavedStory]
|
@Query private var savedStories: [SavedStory]
|
||||||
|
@AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .dark
|
||||||
@State private var showShare = false
|
@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 article: Article? { vm.article }
|
|
||||||
private var isCached: Bool { cachedArticles.contains { $0.id == route.articleId } }
|
private var isCached: Bool { cachedArticles.contains { $0.id == route.articleId } }
|
||||||
private var isRead: Bool { readStories.contains { $0.id == route.storyId } }
|
private var isRead: Bool { readStories.contains { $0.id == route.storyId } }
|
||||||
private var isSaved: Bool { savedStories.contains { $0.id == route.storyId } }
|
private var isSaved: Bool { savedStories.contains { $0.id == route.storyId } }
|
||||||
|
|
||||||
private var backTitle: String {
|
private var backTitle: String {
|
||||||
let h = route.parentHeadline
|
let h = route.parentHeadline
|
||||||
return h.count > 30 ? String(h.prefix(30)).trimmingCharacters(in: .whitespaces) + "…" : h
|
return h.count > 28 ? String(h.prefix(28)).trimmingCharacters(in: .whitespaces) + "…" : h
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
bg.ignoresSafeArea()
|
||||||
ScrollView {
|
VStack(spacing: 0) {
|
||||||
if let article {
|
progressBar
|
||||||
content(article)
|
scrollContent
|
||||||
} 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)
|
.navigationBarBackButtonHidden(true)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) { backButton }
|
ToolbarItem(placement: .topBarLeading) { backButton }
|
||||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
ToolbarItemGroup(placement: .topBarTrailing) { trailingButtons }
|
||||||
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(bg, for: .navigationBar)
|
||||||
.toolbarBackground(.visible, for: .navigationBar)
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
|
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
|
||||||
.task { await vm.load(route: route, context: modelContext) }
|
.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(.white)
|
||||||
|
.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
|
// MARK: - Actions
|
||||||
|
|
||||||
private func toggleRead() {
|
private func toggleRead() {
|
||||||
let id = route.storyId
|
let id = route.storyId
|
||||||
if let row = (try? modelContext.fetch(
|
if let row = (try? modelContext.fetch(
|
||||||
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
||||||
modelContext.delete(row)
|
modelContext.delete(row)
|
||||||
} else {
|
} else {
|
||||||
modelContext.insert(ReadStory(id: id))
|
modelContext.insert(ReadStory(id: id))
|
||||||
@@ -146,7 +440,7 @@ struct ArticleReaderView: View {
|
|||||||
private func toggleSave() {
|
private func toggleSave() {
|
||||||
let id = route.storyId
|
let id = route.storyId
|
||||||
if let row = (try? modelContext.fetch(
|
if let row = (try? modelContext.fetch(
|
||||||
FetchDescriptor<SavedStory>(predicate: #Predicate { $0.id == id })))?.first {
|
FetchDescriptor<SavedStory>(predicate: #Predicate { $0.id == id })))?.first {
|
||||||
modelContext.delete(row)
|
modelContext.delete(row)
|
||||||
} else {
|
} else {
|
||||||
modelContext.insert(SavedStory(id: id))
|
modelContext.insert(SavedStory(id: id))
|
||||||
@@ -162,133 +456,24 @@ struct ArticleReaderView: View {
|
|||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var backButton: some View {
|
// MARK: - Pulsing skeleton primitive
|
||||||
Button { dismiss() } label: {
|
|
||||||
HStack(spacing: 4) {
|
private struct PulsingSkeleton: View {
|
||||||
Image(systemName: "chevron.left").font(.system(size: 15, weight: .bold))
|
let color: Color
|
||||||
Text(backTitle).font(.system(size: 16, weight: .regular)).lineLimit(1)
|
@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 }
|
||||||
}
|
}
|
||||||
.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.
|
// MARK: - Offline Article reconstruction
|
||||||
|
|
||||||
extension Article {
|
extension Article {
|
||||||
init(cached: CachedArticle) {
|
init(cached: CachedArticle) {
|
||||||
self.init(
|
self.init(
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ struct RootTabView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(Color("KisaniOrange"))
|
.tint(Color("KisaniOrange"))
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
.task { await store.loadStories(refresh: true) }
|
.task { await store.loadStories(refresh: true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ struct LatestView: View {
|
|||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
.jarvisDestinations()
|
.jarvisDestinations()
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +123,6 @@ struct SavedView: View {
|
|||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
.jarvisDestinations()
|
.jarvisDestinations()
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +163,6 @@ struct SearchView: View {
|
|||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
.jarvisDestinations()
|
.jarvisDestinations()
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var searchBar: some View {
|
private var searchBar: some View {
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ struct NotificationsView: View {
|
|||||||
.padding(.horizontal, 20).padding(.vertical, 12)
|
.padding(.horizontal, 20).padding(.vertical, 12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
.navigationBarBackButtonHidden(false)
|
.navigationBarBackButtonHidden(false)
|
||||||
.task { await manager.refreshAuthStatus() }
|
.task { await manager.refreshAuthStatus() }
|
||||||
.onChange(of: settings.morningEnabled) { _, _ in apply() }
|
.onChange(of: settings.morningEnabled) { _, _ in apply() }
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
.confirmationDialog("Clear offline cache?", isPresented: $showClearConfirm, titleVisibility: .visible) {
|
.confirmationDialog("Clear offline cache?", isPresented: $showClearConfirm, titleVisibility: .visible) {
|
||||||
Button("Clear cache", role: .destructive) { CacheMaintenance.clear(modelContext) }
|
Button("Clear cache", role: .destructive) { CacheMaintenance.clear(modelContext) }
|
||||||
|
|||||||
@@ -165,6 +165,36 @@ struct ArticleRoute: Hashable {
|
|||||||
let parentHeadline: String
|
let parentHeadline: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Appearance mode
|
||||||
|
|
||||||
|
enum AppearanceMode: String, CaseIterable {
|
||||||
|
case system, light, dark
|
||||||
|
|
||||||
|
var colorScheme: ColorScheme? {
|
||||||
|
switch self {
|
||||||
|
case .system: return nil
|
||||||
|
case .light: return .light
|
||||||
|
case .dark: return .dark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .system: return "System"
|
||||||
|
case .light: return "Light"
|
||||||
|
case .dark: return "Dark"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .system: return "circle.lefthalf.filled"
|
||||||
|
case .light: return "sun.max"
|
||||||
|
case .dark: return "moon.stars"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Stable feed ordering
|
// MARK: - Stable feed ordering
|
||||||
|
|
||||||
extension StorySummary {
|
extension StorySummary {
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ struct StoryDetailView: View {
|
|||||||
}
|
}
|
||||||
.toolbarBackground(Color.black, for: .navigationBar)
|
.toolbarBackground(Color.black, for: .navigationBar)
|
||||||
.toolbarBackground(.visible, for: .navigationBar)
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
|
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
|
||||||
.task {
|
.task {
|
||||||
markRead()
|
markRead()
|
||||||
|
|||||||
Reference in New Issue
Block a user