diff --git a/Jarvis/JarvisApp.swift b/Jarvis/JarvisApp.swift index 0564695..7d77a1f 100644 --- a/Jarvis/JarvisApp.swift +++ b/Jarvis/JarvisApp.swift @@ -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]) } } diff --git a/Jarvis/Models/Models.swift b/Jarvis/Models/Models.swift index 5839022..bd64738 100644 --- a/Jarvis/Models/Models.swift +++ b/Jarvis/Models/Models.swift @@ -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 diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index ed24614..bca44b2 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -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 { Set(cachedArticles.map(\.storyId)) } private var readStoryIds: Set { Set(readStories.map(\.id)) } + private var savedStoryIds: Set { 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(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(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]) { diff --git a/Jarvis/Views/SecondaryTabs.swift b/Jarvis/Views/SecondaryTabs.swift index eee4da1..b29fde4 100644 --- a/Jarvis/Views/SecondaryTabs.swift +++ b/Jarvis/Views/SecondaryTabs.swift @@ -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 { diff --git a/Jarvis/Views/Shared/ShareSheet.swift b/Jarvis/Views/Shared/ShareSheet.swift new file mode 100644 index 0000000..5acc1d6 --- /dev/null +++ b/Jarvis/Views/Shared/ShareSheet.swift @@ -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) {} +}