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