Add share / save / mark-read toolbar buttons to detail and reader views
Some checks failed
CI / Build · macos-14 · Debug (push) Has been cancelled
CI / Build · macos-15 · Debug (push) Has been cancelled
CI / Build · macos-14 · Release (push) Has been cancelled
CI / Build · macos-15 · Release (push) Has been cancelled

Both StoryDetailView and ArticleReaderView now show three icon buttons in
the navigation bar trailing area:
- Share (square.and.arrow.up) — story headline+summary+URL / article
  headline+sourceUrl, passed to UIActivityViewController via ShareSheet.
- Save (bookmark / bookmark.fill, orange when active) — toggles SavedStory
  in SwiftData; StoryDetailView also ensures a CachedStory exists so the
  Saved tab can display it offline.
- Mark read/unread (checkmark.circle / checkmark.circle.fill, orange when
  read) — toggle mirroring the swipe action already present in the feed.

The read state in StoryDetailView is still auto-marked on open (.task
→ markRead()); the toolbar button lets users undo that without going back
to the feed. ArticleReaderView targets the parent story (route.storyId).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-06-20 20:50:22 +03:00
parent d192815915
commit 053e6341ce
2 changed files with 118 additions and 6 deletions

View File

@@ -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<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
} else {
modelContext.insert(ReadStory(id: id))
}
try? modelContext.save()
}
private func toggleSave() {
let id = route.storyId
if let row = (try? modelContext.fetch(
FetchDescriptor<SavedStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
} else {
modelContext.insert(SavedStory(id: id))
}
try? modelContext.save()
}
private var shareContent: [Any] {
guard let art = vm.article else { return [] }
var items: [Any] = ["\(art.headline)\n\nvia Jarvis"]
if !art.sourceUrl.isEmpty, let url = URL(string: art.sourceUrl) {
items.append(url)
}
return items
}
private var backButton: some View {
Button { dismiss() } label: {
HStack(spacing: 4) {