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:
@@ -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]) {
|
||||
|
||||
Reference in New Issue
Block a user