From 053e6341cefac929a88911c3395b1b857b0fb6e2 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Sat, 20 Jun 2026 20:50:22 +0300 Subject: [PATCH] Add share / save / mark-read toolbar buttons to detail and reader views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Jarvis/Views/Reader/ArticleReaderView.swift | 56 +++++++++++++++++ Jarvis/Views/Story/StoryDetailView.swift | 68 +++++++++++++++++++-- 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/Jarvis/Views/Reader/ArticleReaderView.swift b/Jarvis/Views/Reader/ArticleReaderView.swift index 1a111a4..8630af9 100644 --- a/Jarvis/Views/Reader/ArticleReaderView.swift +++ b/Jarvis/Views/Reader/ArticleReaderView.swift @@ -72,9 +72,14 @@ struct ArticleReaderView: View { @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 @@ -100,13 +105,64 @@ struct ArticleReaderView: View { .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(predicate: #Predicate { $0.id == id })))?.first { + modelContext.delete(row) + } else { + modelContext.insert(ReadStory(id: id)) + } + try? modelContext.save() + } + + private func toggleSave() { + let id = route.storyId + if let row = (try? modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first { + modelContext.delete(row) + } else { + modelContext.insert(SavedStory(id: id)) + } + try? modelContext.save() + } + + private var shareContent: [Any] { + guard let art = vm.article else { return [] } + var items: [Any] = ["\(art.headline)\n\nvia Jarvis"] + if !art.sourceUrl.isEmpty, let url = URL(string: art.sourceUrl) { + items.append(url) + } + return items + } + private var backButton: some View { Button { dismiss() } label: { HStack(spacing: 4) { diff --git a/Jarvis/Views/Story/StoryDetailView.swift b/Jarvis/Views/Story/StoryDetailView.swift index 7db2ee2..19bec4a 100644 --- a/Jarvis/Views/Story/StoryDetailView.swift +++ b/Jarvis/Views/Story/StoryDetailView.swift @@ -33,6 +33,9 @@ struct StoryDetailView: View { @Environment(\.modelContext) private var modelContext @StateObject private var vm = StoryDetailViewModel() @Query private var cachedArticles: [CachedArticle] + @Query private var readStories: [ReadStory] + @Query private var savedStories: [SavedStory] + @State private var showShare = false // Prefer freshly-loaded detail; fall back to the summary we navigated with. private var topic: String { story.topic } @@ -54,6 +57,9 @@ struct StoryDetailView: View { return out } + private var isRead: Bool { readStories.contains { $0.id == story.id } } + private var isSaved: Bool { savedStories.contains { $0.id == story.id } } + private var cachedIds: Set { Set(cachedArticles.filter { $0.storyId == story.id }.map(\.id)) } @@ -99,27 +105,77 @@ struct StoryDetailView: View { .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 { markRead() await vm.load(id: story.id) } } - // MARK: - Read state + // MARK: - Read / Save / Share private func markRead() { let id = story.id - let existing = try? modelContext.fetch( - FetchDescriptor(predicate: #Predicate { $0.id == id }) - ) - if existing?.first == nil { + guard (try? modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first == nil + else { return } + modelContext.insert(ReadStory(id: id)) + try? modelContext.save() + } + + private func toggleRead() { + let id = story.id + if let row = (try? modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first { + modelContext.delete(row) + } else { modelContext.insert(ReadStory(id: id)) - try? modelContext.save() } + try? modelContext.save() + } + + private func toggleSave() { + let id = story.id + if let row = (try? modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first { + modelContext.delete(row) + } else { + // Ensure a CachedStory exists so SavedView can display it. + let alreadyCached = (try? modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first != nil + if !alreadyCached { modelContext.insert(CachedStory(from: story)) } + modelContext.insert(SavedStory(id: id)) + } + try? modelContext.save() + } + + private var shareContent: [Any] { + var items: [Any] = ["\(headline)\n\n\(summary)\n\nvia Jarvis"] + if let first = story.sources.first, let url = URL(string: first.url) { + items.append(url) + } + return items } // MARK: - Pieces