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
|
||||
struct JarvisApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .dark
|
||||
@StateObject private var settings = ServerSettings.shared
|
||||
@StateObject private var store = StoryStore.shared
|
||||
@StateObject private var ws = WebSocketManager.shared
|
||||
@@ -39,6 +40,7 @@ struct JarvisApp: App {
|
||||
OnboardingView()
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(appearanceMode.colorScheme)
|
||||
.environmentObject(settings)
|
||||
.environmentObject(store)
|
||||
.environmentObject(ws)
|
||||
|
||||
@@ -34,7 +34,6 @@ struct ConnectivityView: View {
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.presentationDragIndicator(.visible)
|
||||
.task { await recheck() }
|
||||
}
|
||||
|
||||
@@ -77,7 +77,6 @@ struct AddFeedSheet: View {
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.presentationDetents([.medium])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,6 @@ struct FeedManagerView: View {
|
||||
feedList
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.presentationDragIndicator(.visible)
|
||||
.sheet(isPresented: $showAdd) {
|
||||
AddFeedSheet { url, name in
|
||||
|
||||
@@ -147,7 +147,6 @@ struct SignalFeedView: View {
|
||||
ShareSheet(items: shareItems(story))
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.onChange(of: store.stories) { _, newValue in
|
||||
cacheStories(newValue)
|
||||
}
|
||||
|
||||
@@ -125,5 +125,4 @@ struct StoryRowView: View {
|
||||
}
|
||||
}
|
||||
.background(Color.black)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
// 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 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?
|
||||
@@ -20,14 +29,12 @@ final class ArticleReaderViewModel: ObservableObject {
|
||||
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
|
||||
@@ -39,8 +46,7 @@ final class ArticleReaderViewModel: ObservableObject {
|
||||
private func cache(_ art: Article, context: ModelContext) {
|
||||
let id = art.id
|
||||
let existing = try? context.fetch(
|
||||
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id })
|
||||
)
|
||||
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id }))
|
||||
if existing?.first == nil {
|
||||
context.insert(CachedArticle(from: art))
|
||||
try? context.save()
|
||||
@@ -50,14 +56,12 @@ final class ArticleReaderViewModel: ObservableObject {
|
||||
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 {
|
||||
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 })
|
||||
)) ?? []
|
||||
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,
|
||||
@@ -65,69 +69,359 @@ final class ArticleReaderViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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
|
||||
return h.count > 28 ? String(h.prefix(28)).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)
|
||||
}
|
||||
bg.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
progressBar
|
||||
scrollContent
|
||||
}
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) { backButton }
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
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(.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: 16, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: "888888"))
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(mutedFg)
|
||||
}
|
||||
Button { toggleSave() } label: {
|
||||
Image(systemName: isSaved ? "bookmark.fill" : "bookmark")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(isSaved ? Palette.orange : Color(hex: "888888"))
|
||||
.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: 17, weight: .semibold))
|
||||
.foregroundStyle(isRead ? Palette.orange : Color(hex: "888888"))
|
||||
.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)
|
||||
}
|
||||
.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
|
||||
@@ -162,133 +456,24 @@ struct ArticleReaderView: View {
|
||||
}
|
||||
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()
|
||||
}
|
||||
// MARK: - Pulsing skeleton primitive
|
||||
|
||||
Text(article.headline)
|
||||
.font(.system(size: 26, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
private struct PulsingSkeleton: View {
|
||||
let color: Color
|
||||
@State private var on = false
|
||||
|
||||
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)
|
||||
var body: some View {
|
||||
color.opacity(on ? 0.9 : 0.45)
|
||||
.onAppear {
|
||||
withAnimation(.easeInOut(duration: 0.85).repeatForever()) { on = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
// MARK: - Offline Article reconstruction
|
||||
|
||||
// Reconstruct an Article from its cached copy for offline display.
|
||||
extension Article {
|
||||
init(cached: CachedArticle) {
|
||||
self.init(
|
||||
|
||||
@@ -28,7 +28,6 @@ struct RootTabView: View {
|
||||
}
|
||||
}
|
||||
.tint(Color("KisaniOrange"))
|
||||
.preferredColorScheme(.dark)
|
||||
.task { await store.loadStories(refresh: true) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,6 @@ struct LatestView: View {
|
||||
.navigationBarHidden(true)
|
||||
.jarvisDestinations()
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +123,6 @@ struct SavedView: View {
|
||||
.navigationBarHidden(true)
|
||||
.jarvisDestinations()
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +163,6 @@ struct SearchView: View {
|
||||
.navigationBarHidden(true)
|
||||
.jarvisDestinations()
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
private var searchBar: some View {
|
||||
|
||||
@@ -33,7 +33,6 @@ struct NotificationsView: View {
|
||||
.padding(.horizontal, 20).padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.navigationBarBackButtonHidden(false)
|
||||
.task { await manager.refreshAuthStatus() }
|
||||
.onChange(of: settings.morningEnabled) { _, _ in apply() }
|
||||
|
||||
@@ -51,7 +51,6 @@ struct SettingsView: View {
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.presentationDragIndicator(.visible)
|
||||
.confirmationDialog("Clear offline cache?", isPresented: $showClearConfirm, titleVisibility: .visible) {
|
||||
Button("Clear cache", role: .destructive) { CacheMaintenance.clear(modelContext) }
|
||||
|
||||
@@ -165,6 +165,36 @@ struct ArticleRoute: Hashable {
|
||||
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
|
||||
|
||||
extension StorySummary {
|
||||
|
||||
@@ -125,7 +125,6 @@ struct StoryDetailView: View {
|
||||
}
|
||||
.toolbarBackground(Color.black, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.preferredColorScheme(.dark)
|
||||
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
|
||||
.task {
|
||||
markRead()
|
||||
|
||||
Reference in New Issue
Block a user