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

@@ -51,6 +51,6 @@ struct JarvisApp: App {
await notifications.bootstrap()
}
}
.modelContainer(for: [CachedStory.self, CachedArticle.self, ReadStory.self])
.modelContainer(for: [CachedStory.self, CachedArticle.self, ReadStory.self, SavedStory.self])
}
}

View File

@@ -199,6 +199,17 @@ final class ReadStory {
}
}
@Model
final class SavedStory {
@Attribute(.unique) var id: String
var savedAt: Date
init(id: String) {
self.id = id
self.savedAt = Date()
}
}
@Model
final class CachedArticle {
@Attribute(.unique) var id: String

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,10 +201,12 @@ struct SignalFeedView: View {
// MARK: - Feed list
private var feedList: some View {
ScrollView {
LazyVStack(spacing: 0) {
List {
if displayStories.isEmpty {
emptyState
.listRowInsets(EdgeInsets())
.listRowBackground(Color.black)
.listRowSeparator(.hidden)
} else {
ForEach(displayStories) { story in
NavigationLink(value: story) {
@@ -206,22 +214,47 @@ struct SignalFeedView: View {
isCached: cachedStoryIds.contains(story.id),
isRead: readStoryIds.contains(story.id))
}
.buttonStyle(.plain)
.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)
.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]) {

View File

@@ -86,9 +86,14 @@ struct SavedView: View {
@Query private var cachedStories: [CachedStory]
@Query private var cachedArticles: [CachedArticle]
@Query private var readStories: [ReadStory]
@Query private var savedStories: [SavedStory]
private var stories: [StorySummary] {
cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
let savedIds = Set(savedStories.map(\.id))
return cachedStories
.filter { savedIds.contains($0.id) }
.map(StorySummary.init(cached:))
.sorted(by: StorySummary.feedOrder)
}
var body: some View {
@@ -96,15 +101,18 @@ struct SavedView: View {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 0) {
TabHeader(title: "Saved · offline")
TabHeader(title: "Saved")
Divider().overlay(Palette.hairline)
if stories.isEmpty {
VStack(spacing: 12) {
Image(systemName: "bookmark")
.font(.system(size: 28)).foregroundStyle(Color(hex: "333333"))
Text("Nothing cached yet")
Text("Nothing saved yet")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(Color(hex: "555555"))
Text("Swipe a story left to save it")
.font(.system(size: 12))
.foregroundStyle(Color(hex: "444444"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {

View File

@@ -0,0 +1,15 @@
// ShareSheet.swift
// Jarvis thin wrapper around UIActivityViewController for the share action.
import SwiftUI
import UIKit
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
}