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:
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
15
Jarvis/Views/Shared/ShareSheet.swift
Normal file
15
Jarvis/Views/Shared/ShareSheet.swift
Normal 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) {}
|
||||
}
|
||||
Reference in New Issue
Block a user