Add share / save / mark-read toolbar buttons to detail and reader views
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:
@@ -72,9 +72,14 @@ struct ArticleReaderView: View {
|
|||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@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 savedStories: [SavedStory]
|
||||||
|
@State private var showShare = false
|
||||||
|
|
||||||
private var article: Article? { vm.article }
|
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 isSaved: Bool { savedStories.contains { $0.id == route.storyId } }
|
||||||
|
|
||||||
private var backTitle: String {
|
private var backTitle: String {
|
||||||
let h = route.parentHeadline
|
let h = route.parentHeadline
|
||||||
@@ -100,13 +105,64 @@ struct ArticleReaderView: View {
|
|||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) { backButton }
|
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(Color.black, for: .navigationBar)
|
||||||
.toolbarBackground(.visible, for: .navigationBar)
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
|
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
|
||||||
.task { await vm.load(route: route, context: modelContext) }
|
.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 {
|
private var backButton: some View {
|
||||||
Button { dismiss() } label: {
|
Button { dismiss() } label: {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ struct StoryDetailView: View {
|
|||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@StateObject private var vm = StoryDetailViewModel()
|
@StateObject private var vm = StoryDetailViewModel()
|
||||||
@Query private var cachedArticles: [CachedArticle]
|
@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.
|
// Prefer freshly-loaded detail; fall back to the summary we navigated with.
|
||||||
private var topic: String { story.topic }
|
private var topic: String { story.topic }
|
||||||
@@ -54,6 +57,9 @@ struct StoryDetailView: View {
|
|||||||
return out
|
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<String> {
|
private var cachedIds: Set<String> {
|
||||||
Set(cachedArticles.filter { $0.storyId == story.id }.map(\.id))
|
Set(cachedArticles.filter { $0.storyId == story.id }.map(\.id))
|
||||||
}
|
}
|
||||||
@@ -99,27 +105,77 @@ struct StoryDetailView: View {
|
|||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) { backButton }
|
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(Color.black, for: .navigationBar)
|
||||||
.toolbarBackground(.visible, for: .navigationBar)
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
|
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
|
||||||
.task {
|
.task {
|
||||||
markRead()
|
markRead()
|
||||||
await vm.load(id: story.id)
|
await vm.load(id: story.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Read state
|
// MARK: - Read / Save / Share
|
||||||
|
|
||||||
private func markRead() {
|
private func markRead() {
|
||||||
let id = story.id
|
let id = story.id
|
||||||
let existing = try? modelContext.fetch(
|
guard (try? modelContext.fetch(
|
||||||
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })
|
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first == nil
|
||||||
)
|
else { return }
|
||||||
if existing?.first == nil {
|
modelContext.insert(ReadStory(id: id))
|
||||||
|
try? modelContext.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleRead() {
|
||||||
|
let id = story.id
|
||||||
|
if let row = (try? modelContext.fetch(
|
||||||
|
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
||||||
|
modelContext.delete(row)
|
||||||
|
} else {
|
||||||
modelContext.insert(ReadStory(id: id))
|
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<SavedStory>(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<CachedStory>(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
|
// MARK: - Pieces
|
||||||
|
|||||||
Reference in New Issue
Block a user