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() 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 @Model
final class CachedArticle { final class CachedArticle {
@Attribute(.unique) var id: String @Attribute(.unique) var id: String

View File

@@ -24,13 +24,16 @@ struct SignalFeedView: View {
@Query private var cachedStories: [CachedStory] @Query private var cachedStories: [CachedStory]
@Query private var cachedArticles: [CachedArticle] @Query private var cachedArticles: [CachedArticle]
@Query private var readStories: [ReadStory] @Query private var readStories: [ReadStory]
@Query private var savedStories: [SavedStory]
@State private var showFeeds = false @State private var showFeeds = false
@State private var showSettings = false @State private var showSettings = false
@State private var selectedInterest: Interest? = nil @State private var selectedInterest: Interest? = nil
@State private var shareStory: StorySummary?
/// Stories that have full article content cached eligible for the green dot. /// Stories that have full article content cached eligible for the green dot.
private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) } private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) }
private var readStoryIds: Set<String> { Set(readStories.map(\.id)) } 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. /// Live stories when online; cached fallback when the feed is empty & offline.
private var displayStories: [StorySummary] { private var displayStories: [StorySummary] {
@@ -81,6 +84,9 @@ struct SignalFeedView: View {
.sheet(isPresented: $showSettings) { .sheet(isPresented: $showSettings) {
SettingsView() SettingsView()
} }
.sheet(item: $shareStory) { story in
ShareSheet(items: shareItems(story))
}
} }
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.onChange(of: store.stories) { _, newValue in .onChange(of: store.stories) { _, newValue in
@@ -195,33 +201,60 @@ struct SignalFeedView: View {
// MARK: - Feed list // MARK: - Feed list
private var feedList: some View { private var feedList: some View {
ScrollView { List {
LazyVStack(spacing: 0) { if displayStories.isEmpty {
if displayStories.isEmpty { emptyState
emptyState .listRowInsets(EdgeInsets())
} else { .listRowBackground(Color.black)
ForEach(displayStories) { story in .listRowSeparator(.hidden)
NavigationLink(value: story) { } else {
StoryRowView(story: story, ForEach(displayStories) { story in
isCached: cachedStoryIds.contains(story.id), NavigationLink(value: story) {
isRead: readStoryIds.contains(story.id)) StoryRowView(story: story,
} isCached: cachedStoryIds.contains(story.id),
.buttonStyle(.plain) isRead: readStoryIds.contains(story.id))
.onAppear { }
if story.id == displayStories.last?.id { .listRowInsets(EdgeInsets())
Task { await store.loadMore() } .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 { if store.isLoadingMore {
ProgressView() ProgressView()
.tint(Palette.orange) .tint(Palette.orange)
.padding(.vertical, 20) .frame(maxWidth: .infinity)
} .padding(.vertical, 20)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.black)
.listRowSeparator(.hidden)
} }
} }
} }
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.black)
.refreshable { .refreshable {
await store.loadStories(refresh: true) await store.loadStories(refresh: true)
} }
@@ -303,6 +336,41 @@ struct SignalFeedView: View {
.padding(.top, 6) .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 // MARK: - Caching
private func cacheStories(_ stories: [StorySummary]) { private func cacheStories(_ stories: [StorySummary]) {

View File

@@ -86,9 +86,14 @@ struct SavedView: View {
@Query private var cachedStories: [CachedStory] @Query private var cachedStories: [CachedStory]
@Query private var cachedArticles: [CachedArticle] @Query private var cachedArticles: [CachedArticle]
@Query private var readStories: [ReadStory] @Query private var readStories: [ReadStory]
@Query private var savedStories: [SavedStory]
private var stories: [StorySummary] { 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 { var body: some View {
@@ -96,15 +101,18 @@ struct SavedView: View {
ZStack { ZStack {
Color.black.ignoresSafeArea() Color.black.ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 0) {
TabHeader(title: "Saved · offline") TabHeader(title: "Saved")
Divider().overlay(Palette.hairline) Divider().overlay(Palette.hairline)
if stories.isEmpty { if stories.isEmpty {
VStack(spacing: 12) { VStack(spacing: 12) {
Image(systemName: "bookmark") Image(systemName: "bookmark")
.font(.system(size: 28)).foregroundStyle(Color(hex: "333333")) .font(.system(size: 28)).foregroundStyle(Color(hex: "333333"))
Text("Nothing cached yet") Text("Nothing saved yet")
.font(.system(size: 14, weight: .bold)) .font(.system(size: 14, weight: .bold))
.foregroundStyle(Color(hex: "555555")) .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) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } 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) {}
}