From 7085a4dd459399644079e71f8d5d851486ebd8af Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Sun, 21 Jun 2026 01:30:36 +0300 Subject: [PATCH] feat(reader): redesign ArticleReaderView + global appearance toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Jarvis/JarvisApp.swift | 2 + .../Views/Connectivity/ConnectivityView.swift | 1 - Jarvis/Views/Feeds/AddFeedSheet.swift | 1 - Jarvis/Views/Feeds/FeedManagerView.swift | 1 - Jarvis/Views/Home/SignalFeedView.swift | 1 - Jarvis/Views/Home/StoryRowView.swift | 1 - Jarvis/Views/Reader/ArticleReaderView.swift | 521 ++++++++++++------ Jarvis/Views/RootTabView.swift | 1 - Jarvis/Views/SecondaryTabs.swift | 3 - Jarvis/Views/Settings/NotificationsView.swift | 1 - Jarvis/Views/Settings/SettingsView.swift | 1 - Jarvis/Views/Shared/Theme.swift | 30 + Jarvis/Views/Story/StoryDetailView.swift | 1 - 13 files changed, 385 insertions(+), 180 deletions(-) diff --git a/Jarvis/JarvisApp.swift b/Jarvis/JarvisApp.swift index e0759a5..c1cc1d4 100644 --- a/Jarvis/JarvisApp.swift +++ b/Jarvis/JarvisApp.swift @@ -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) diff --git a/Jarvis/Views/Connectivity/ConnectivityView.swift b/Jarvis/Views/Connectivity/ConnectivityView.swift index 4188fb8..16c0d14 100644 --- a/Jarvis/Views/Connectivity/ConnectivityView.swift +++ b/Jarvis/Views/Connectivity/ConnectivityView.swift @@ -34,7 +34,6 @@ struct ConnectivityView: View { .padding(.vertical, 12) } } - .preferredColorScheme(.dark) .presentationDragIndicator(.visible) .task { await recheck() } } diff --git a/Jarvis/Views/Feeds/AddFeedSheet.swift b/Jarvis/Views/Feeds/AddFeedSheet.swift index bd586fb..f5acb0f 100644 --- a/Jarvis/Views/Feeds/AddFeedSheet.swift +++ b/Jarvis/Views/Feeds/AddFeedSheet.swift @@ -77,7 +77,6 @@ struct AddFeedSheet: View { } .padding(.horizontal, 20) } - .preferredColorScheme(.dark) .presentationDetents([.medium]) .presentationDragIndicator(.visible) } diff --git a/Jarvis/Views/Feeds/FeedManagerView.swift b/Jarvis/Views/Feeds/FeedManagerView.swift index b07e1d9..aca8720 100644 --- a/Jarvis/Views/Feeds/FeedManagerView.swift +++ b/Jarvis/Views/Feeds/FeedManagerView.swift @@ -94,7 +94,6 @@ struct FeedManagerView: View { feedList } } - .preferredColorScheme(.dark) .presentationDragIndicator(.visible) .sheet(isPresented: $showAdd) { AddFeedSheet { url, name in diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index 7fb6d71..d65a85d 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -147,7 +147,6 @@ struct SignalFeedView: View { ShareSheet(items: shareItems(story)) } } - .preferredColorScheme(.dark) .onChange(of: store.stories) { _, newValue in cacheStories(newValue) } diff --git a/Jarvis/Views/Home/StoryRowView.swift b/Jarvis/Views/Home/StoryRowView.swift index 187b809..038152f 100644 --- a/Jarvis/Views/Home/StoryRowView.swift +++ b/Jarvis/Views/Home/StoryRowView.swift @@ -125,5 +125,4 @@ struct StoryRowView: View { } } .background(Color.black) - .preferredColorScheme(.dark) } diff --git a/Jarvis/Views/Reader/ArticleReaderView.swift b/Jarvis/Views/Reader/ArticleReaderView.swift index 8630af9..2592ac7 100644 --- a/Jarvis/Views/Reader/ArticleReaderView.swift +++ b/Jarvis/Views/Reader/ArticleReaderView.swift @@ -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(predicate: #Predicate { $0.id == id }) - ) + FetchDescriptor(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(predicate: #Predicate { $0.id == id }) - ))?.first { + 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 }) - )) ?? [] + FetchDescriptor(predicate: #Predicate { $0.storyId == sid }))) ?? [] siblings = arts .filter { $0.id != route.articleId } .map { TimelineEntry(articleId: $0.id, source: $0.source, headline: $0.headline, @@ -65,77 +69,367 @@ 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 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) { - 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")) - } - } + ToolbarItemGroup(placement: .topBarTrailing) { trailingButtons } } - .toolbarBackground(Color.black, for: .navigationBar) + .toolbarBackground(bg, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) - .preferredColorScheme(.dark) .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: 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 { + FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first { modelContext.delete(row) } else { modelContext.insert(ReadStory(id: id)) @@ -146,7 +440,7 @@ struct ArticleReaderView: View { private func toggleSave() { let id = route.storyId if let row = (try? modelContext.fetch( - FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first { + FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first { modelContext.delete(row) } else { modelContext.insert(SavedStory(id: id)) @@ -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) +// 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 } } - .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 { init(cached: CachedArticle) { self.init( diff --git a/Jarvis/Views/RootTabView.swift b/Jarvis/Views/RootTabView.swift index 66fd036..fcaf514 100644 --- a/Jarvis/Views/RootTabView.swift +++ b/Jarvis/Views/RootTabView.swift @@ -28,7 +28,6 @@ struct RootTabView: View { } } .tint(Color("KisaniOrange")) - .preferredColorScheme(.dark) .task { await store.loadStories(refresh: true) } } } diff --git a/Jarvis/Views/SecondaryTabs.swift b/Jarvis/Views/SecondaryTabs.swift index b29fde4..6aba1a5 100644 --- a/Jarvis/Views/SecondaryTabs.swift +++ b/Jarvis/Views/SecondaryTabs.swift @@ -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 { diff --git a/Jarvis/Views/Settings/NotificationsView.swift b/Jarvis/Views/Settings/NotificationsView.swift index fcb119c..4027bef 100644 --- a/Jarvis/Views/Settings/NotificationsView.swift +++ b/Jarvis/Views/Settings/NotificationsView.swift @@ -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() } diff --git a/Jarvis/Views/Settings/SettingsView.swift b/Jarvis/Views/Settings/SettingsView.swift index b5a1896..b806bf6 100644 --- a/Jarvis/Views/Settings/SettingsView.swift +++ b/Jarvis/Views/Settings/SettingsView.swift @@ -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) } diff --git a/Jarvis/Views/Shared/Theme.swift b/Jarvis/Views/Shared/Theme.swift index 100bf78..9b30c43 100644 --- a/Jarvis/Views/Shared/Theme.swift +++ b/Jarvis/Views/Shared/Theme.swift @@ -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 { diff --git a/Jarvis/Views/Story/StoryDetailView.swift b/Jarvis/Views/Story/StoryDetailView.swift index 19bec4a..d0f0b2d 100644 --- a/Jarvis/Views/Story/StoryDetailView.swift +++ b/Jarvis/Views/Story/StoryDetailView.swift @@ -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()