Add swipe actions: left to save, right to share + mark read

- Swipe left bookmarks a story (toggle) into the Saved tab; swipe right opens
  the iOS share sheet and marks the story read.
- New SavedStory SwiftData model; Saved tab now lists explicit bookmarks
  instead of the whole offline cache.
- Convert the feed to a styled List (native swipeActions require it), keeping
  the flat look, pull-to-refresh, and infinite scroll. Add a ShareSheet wrapper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-06-19 01:15:56 +03:00
parent 87bfb234c2
commit 3fd5b8f1c7
5 changed files with 127 additions and 25 deletions

View File

@@ -24,13 +24,16 @@ struct SignalFeedView: View {
@Query private var cachedStories: [CachedStory]
@Query private var cachedArticles: [CachedArticle]
@Query private var readStories: [ReadStory]
@Query private var savedStories: [SavedStory]
@State private var showFeeds = false
@State private var showSettings = false
@State private var selectedInterest: Interest? = nil
@State private var shareStory: StorySummary?
/// Stories that have full article content cached eligible for the green dot.
private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) }
private var readStoryIds: Set<String> { Set(readStories.map(\.id)) }
private var savedStoryIds: Set<String> { Set(savedStories.map(\.id)) }
/// Live stories when online; cached fallback when the feed is empty & offline.
private var displayStories: [StorySummary] {
@@ -81,6 +84,9 @@ struct SignalFeedView: View {
.sheet(isPresented: $showSettings) {
SettingsView()
}
.sheet(item: $shareStory) { story in
ShareSheet(items: shareItems(story))
}
}
.preferredColorScheme(.dark)
.onChange(of: store.stories) { _, newValue in
@@ -195,33 +201,60 @@ struct SignalFeedView: View {
// MARK: - Feed list
private var feedList: some View {
ScrollView {
LazyVStack(spacing: 0) {
if displayStories.isEmpty {
emptyState
} else {
ForEach(displayStories) { story in
NavigationLink(value: story) {
StoryRowView(story: story,
isCached: cachedStoryIds.contains(story.id),
isRead: readStoryIds.contains(story.id))
}
.buttonStyle(.plain)
.onAppear {
if story.id == displayStories.last?.id {
Task { await store.loadMore() }
}
List {
if displayStories.isEmpty {
emptyState
.listRowInsets(EdgeInsets())
.listRowBackground(Color.black)
.listRowSeparator(.hidden)
} else {
ForEach(displayStories) { story in
NavigationLink(value: story) {
StoryRowView(story: story,
isCached: cachedStoryIds.contains(story.id),
isRead: readStoryIds.contains(story.id))
}
.listRowInsets(EdgeInsets())
.listRowBackground(Color.black)
.listRowSeparator(.hidden)
.onAppear {
if story.id == displayStories.last?.id {
Task { await store.loadMore() }
}
}
// Swipe left Save · swipe right Share + mark read
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { toggleSave(story) } label: {
Label(isSaved(story) ? "Saved" : "Save",
systemImage: isSaved(story) ? "bookmark.fill" : "bookmark")
}
.tint(Palette.healthActive)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
markRead(story)
shareStory = story
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
.tint(Palette.orange)
}
}
if store.isLoadingMore {
ProgressView()
.tint(Palette.orange)
.padding(.vertical, 20)
}
if store.isLoadingMore {
ProgressView()
.tint(Palette.orange)
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.black)
.listRowSeparator(.hidden)
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.black)
.refreshable {
await store.loadStories(refresh: true)
}
@@ -303,6 +336,41 @@ struct SignalFeedView: View {
.padding(.top, 6)
}
// MARK: - Swipe actions
private func isSaved(_ s: StorySummary) -> Bool { savedStoryIds.contains(s.id) }
/// Swipe left toggle Save (bookmarks the story to the Saved tab).
private func toggleSave(_ s: StorySummary) {
let id = s.id
if let row = (try? modelContext.fetch(
FetchDescriptor<SavedStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
} else {
cacheStories([s]) // keep metadata so it renders in Saved
modelContext.insert(SavedStory(id: id))
}
try? modelContext.save()
}
private func markRead(_ s: StorySummary) {
let id = s.id
let existing = try? modelContext.fetch(
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id }))
if existing?.first == nil {
modelContext.insert(ReadStory(id: id))
try? modelContext.save()
}
}
private func shareItems(_ s: StorySummary) -> [Any] {
var items: [Any] = ["\(s.headline)\n\n\(s.summary)\n\nvia Jarvis"]
if let first = s.sources.first, let url = URL(string: first.url) {
items.append(url)
}
return items
}
// MARK: - Caching
private func cacheStories(_ stories: [StorySummary]) {