From e63b4f9b2c8dfb4e62d07c89862cca4a16527883 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Thu, 16 Nov 2023 16:07:50 +0800 Subject: [PATCH] Abstract out fetching from view model so we can better handle multiple fetch folders Rename LinkedItem to LibraryItem More on following Add new fetcher Tab bar --- .../xcshareddata/swiftpm/Package.resolved | 36 + apple/OmnivoreKit/Package.swift | 7 +- .../Share/ShareExtensionViewModel.swift | 4 +- .../Sources/App/Views/BriefingView.swift | 237 ++-- .../Views/Highlights/NotebookViewModel.swift | 6 +- .../Components/FeedCardNavigationLink.swift | 78 +- .../Home/Components/FollowingFetcher.swift | 257 ++++ .../Views/Home/Components/InboxFetcher.swift | 276 +++++ .../LibraryFeatureCardNavigationLink.swift | 2 +- .../App/Views/Home/FilterSelectorView.swift | 121 ++ .../App/Views/Home/HomeFeedViewIOS.swift | 169 ++- .../App/Views/Home/HomeFeedViewMac.swift | 4 +- .../App/Views/Home/HomeFeedViewModel.swift | 290 +---- .../App/Views/Home/LibraryItemFetcher.swift | 43 + .../App/Views/Home/LibraryItemMenu.swift | 56 +- .../App/Views/Home/LibraryListView.swift | 19 +- .../App/Views/Home/LibrarySearchView.swift | 10 +- .../App/Views/Labels/ApplyLabelsView.swift | 2 +- .../App/Views/Labels/LabelsViewModel.swift | 2 +- .../Sources/App/Views/LibraryTabView.swift | 40 +- .../App/Views/LinkItemDetailView.swift | 12 +- .../Views/LinkedItemMetadataEditView.swift | 8 +- .../App/Views/PrimaryContentView.swift | 4 +- .../App/Views/Profile/ProfileView.swift | 7 +- .../App/Views/RemoveLibraryItemAction.swift | 4 +- .../App/Views/TabBar/CustomTabBar.swift | 41 + .../App/Views/WebReader/WebReader.swift | 2 +- .../Views/WebReader/WebReaderContainer.swift | 4 +- .../Views/WebReader/WebReaderContent.swift | 4 +- .../WebReader/WebReaderLoadingContainer.swift | 7 +- .../Views/WebReader/WebReaderViewModel.swift | 4 +- .../CoreDataModel.xcdatamodel/contents | 9 +- .../Sources/Models/DataModels/FeedItem.swift | 9 +- .../Sources/Models/DataModels/PDFItem.swift | 2 +- .../Sources/Models/InboxFilters.swift | 178 +-- .../Sources/Models/LinkedItemFilter.swift | 227 ++++ .../Sources/Models/LinkedItemSort.swift | 10 +- .../Services/DataService/ContentLoading.swift | 10 +- .../Services/DataService/DataService.swift | 6 +- .../FetchLinkedItemsBackgroundTask.swift | 2 +- .../Services/DataService/GQLSchema.swift | 1088 +++++++++++++++++ .../DataService/Mutations/ArchiveLink.swift | 4 +- .../Mutations/BulkActionMutation.swift | 2 +- .../DataService/Mutations/RemoveLink.swift | 6 +- .../DataService/Mutations/UndeleteItem.swift | 2 +- .../UpdateArticleLabelsPublisher.swift | 4 +- ...UpdateArticleListenProgressPublisher.swift | 2 +- .../UpdateArticleReadingProgress.swift | 4 +- .../Mutations/UpdateLinkedItemTitle.swift | 4 +- .../Services/DataService/OfflineSync.swift | 10 +- .../Public/LinkedItemLoading.swift | 2 +- .../DataService/Public/PDFLoading.swift | 4 +- .../Queries/ArticleContentQuery.swift | 5 +- .../Queries/LinkedItemNetworkQuery.swift | 20 +- .../InternalModels/InternalFilter.swift | 14 +- .../InternalModels/InternalHighlight.swift | 2 +- ...edItem.swift => InternalLibraryItem.swift} | 17 +- .../Sources/Views/Colors/Colors.swift | 2 + .../_themeTabBarColor.colorset/Contents.json | 38 + .../Contents.json | 38 + .../Sources/Views/FeedItem/GridCard.swift | 4 +- .../Views/FeedItem/LibraryFeatureCard.swift | 4 +- .../Views/FeedItem/LibraryItemCard.swift | 6 +- .../Sources/Views/Images/Images.swift | 8 +- .../_profileTab.imageset/Contents.json | 12 - .../_profileTab.imageset/profile-tab.svg | 5 - .../Contents.json | 12 - .../profile-tab-selected.svg | 6 - .../Group 1000002644.png | Bin 1104 -> 0 bytes .../Contents.json | 2 +- .../_tab_following.imageset/Frame.svg | 12 + .../_tab_library.imageset/BookOpen.png | Bin 482 -> 0 bytes .../_tab_library.imageset/Contents.json | 2 +- .../_tab_library.imageset/Frame.svg | 11 + .../Contents.json | 2 +- .../_tab_search.imageset/MagnifyingGlass.png | Bin 0 -> 552 bytes .../BookmarksSimple.png | Bin 707 -> 0 bytes apple/OmnivoreKit/Sources/Views/Theme.swift | 2 +- apple/swiftgraphql.yml | 1 + 79 files changed, 2758 insertions(+), 808 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/App/Views/Home/Components/FollowingFetcher.swift create mode 100644 apple/OmnivoreKit/Sources/App/Views/Home/Components/InboxFetcher.swift create mode 100644 apple/OmnivoreKit/Sources/App/Views/Home/FilterSelectorView.swift create mode 100644 apple/OmnivoreKit/Sources/App/Views/Home/LibraryItemFetcher.swift create mode 100644 apple/OmnivoreKit/Sources/App/Views/TabBar/CustomTabBar.swift create mode 100644 apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift rename apple/OmnivoreKit/Sources/Services/InternalModels/{InternalLinkedItem.swift => InternalLibraryItem.swift} (91%) create mode 100644 apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_themeTabBarColor.colorset/Contents.json create mode 100644 apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_themeTabButtonColor.colorset/Contents.json delete mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_profileTab.imageset/Contents.json delete mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_profileTab.imageset/profile-tab.svg delete mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_profileTabSelected.imageset/Contents.json delete mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_profileTabSelected.imageset/profile-tab-selected.svg delete mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_briefing.imageset/Group 1000002644.png rename apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/{_tab_subscriptions.imageset => _tab_following.imageset}/Contents.json (89%) create mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_following.imageset/Frame.svg delete mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_library.imageset/BookOpen.png create mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_library.imageset/Frame.svg rename apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/{_tab_briefing.imageset => _tab_search.imageset}/Contents.json (88%) create mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_search.imageset/MagnifyingGlass.png delete mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_subscriptions.imageset/BookmarksSimple.png diff --git a/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved index d60e90e69..3fd58797a 100644 --- a/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -27,6 +27,15 @@ "version" : "0.9.0" } }, + { + "identity" : "engine", + "kind" : "remoteSourceControl", + "location" : "https://github.com/nathantannar4/Engine", + "state" : { + "revision" : "31949c114698e4fd43fd76290913bca415fa87bc", + "version" : "1.1.0" + } + }, { "identity" : "files", "kind" : "remoteSourceControl", @@ -207,6 +216,15 @@ "version" : "1.19.0" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", + "version" : "509.0.2" + } + }, { "identity" : "swiftformat", "kind" : "remoteSourceControl", @@ -225,6 +243,24 @@ "version" : "0.1.4" } }, + { + "identity" : "transmission", + "kind" : "remoteSourceControl", + "location" : "https://github.com/nathantannar4/Transmission", + "state" : { + "revision" : "9517912f8f528c777f86f7896b5c35d7e43fa916", + "version" : "1.0.1" + } + }, + { + "identity" : "turbocharger", + "kind" : "remoteSourceControl", + "location" : "https://github.com/nathantannar4/Turbocharger", + "state" : { + "revision" : "b4201ba0bc094facf6cabe3b36fd3763b51ccfc8", + "version" : "1.0.1" + } + }, { "identity" : "valet", "kind" : "remoteSourceControl", diff --git a/apple/OmnivoreKit/Package.swift b/apple/OmnivoreKit/Package.swift index 46084404c..4698e8436 100644 --- a/apple/OmnivoreKit/Package.swift +++ b/apple/OmnivoreKit/Package.swift @@ -15,6 +15,7 @@ let package = Package( .library(name: "Services", targets: ["Services"]), .library(name: "Models", targets: ["Models"]), .library(name: "Utils", targets: ["Utils"]) + ], dependencies: dependencies, targets: [ @@ -26,7 +27,8 @@ let package = Package( "Models", .product(name: "Introspect", package: "SwiftUI-Introspect"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), - .productItem(name: "PopupView", package: "PopupView") + .productItem(name: "PopupView", package: "PopupView"), + .product(name: "Transmission", package: "Transmission") ], resources: [.process("Resources")] ), @@ -70,7 +72,8 @@ var dependencies: [Package.Dependency] { .package(url: "https://github.com/google/GoogleSignIn-iOS", from: "6.2.2"), .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.0.0"), .package(url: "https://github.com/exyte/PopupView.git", from: "2.6.0"), - .package(url: "https://github.com/PostHog/posthog-ios.git", from: "2.0.0") + .package(url: "https://github.com/PostHog/posthog-ios.git", from: "2.0.0"), + .package(url: "https://github.com/nathantannar4/Transmission", from: "1.0.1") ] // Comment out following line for macOS build deps.append(.package(url: "https://github.com/PSPDFKit/PSPDFKit-SP", from: "13.1.0")) diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift index 61814a5ad..bc99263ed 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift @@ -11,7 +11,7 @@ public class ShareExtensionViewModel: ObservableObject { @Published public var url: String? @Published public var iconURL: URL? @Published public var highlightData: HighlightData? - @Published public var linkedItem: LinkedItem? + @Published public var linkedItem: Models.LibraryItem? @Published public var requestId = UUID().uuidString.lowercased() @Published var debugText: String? @Published var noteText: String = "" @@ -204,7 +204,7 @@ public class ShareExtensionViewModel: ObservableObject { } if let objectID = objectID { - self.linkedItem = self.services.dataService.viewContext.object(with: objectID) as? LinkedItem + self.linkedItem = self.services.dataService.viewContext.object(with: objectID) as? Models.LibraryItem if let title = self.linkedItem?.title { self.title = title } diff --git a/apple/OmnivoreKit/Sources/App/Views/BriefingView.swift b/apple/OmnivoreKit/Sources/App/Views/BriefingView.swift index 6c781c48d..417b0c1ec 100644 --- a/apple/OmnivoreKit/Sources/App/Views/BriefingView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/BriefingView.swift @@ -1,3 +1,5 @@ +// swiftlint:disable line_length + import CoreData import Models import Services @@ -5,155 +7,122 @@ import SwiftUI import Utils import Views -@MainActor final class BriefingViewModel: ObservableObject { - @Published var item: LinkedItem? +let BRIEFING = """ +## Inbox - func loadItem(articleId: String, dataService: DataService) async { - let item = await dataService.viewContext.perform { - LinkedItem.lookup(byID: articleId, inContext: dataService.viewContext) - // dataService.viewContext.object(with: linkedItemObjectID) as? LinkedItem - } +- [ ] Who owns Real Madrid? How 'socios' remain in control of Los Blancos with president Florentino Perez at the helm | Sporting News Singapore | Kyle Bonn - if let item = item { - self.item = item - } - } +## Subscriptions -// func handleArchiveAction(dataService: DataService) { -// guard let objectID = item?.objectID ?? pdfItem?.objectID else { return } -// dataService.archiveLink(objectID: objectID, archived: !isItemArchived) -// showInSnackbar(!isItemArchived ? "Link archived" : "Link moved to Inbox") -// } -// -// func handleDeleteAction(dataService: DataService) { -// guard let objectID = item?.objectID ?? pdfItem?.objectID else { return } -// showInSnackbar("Link removed") -// dataService.removeLink(objectID: objectID) -// } -// -// func updateItemReadStatus(dataService: DataService) { -// guard let itemID = item?.unwrappedID ?? pdfItem?.itemID else { return } -// -// dataService.updateLinkReadingProgress( -// itemID: itemID, -// readingProgress: isItemRead ? 0 : 100, -// anchorIndex: 0 -// ) -// } +- [ ] Astral Codex Ten + - [ ] In Continued Defense Of Effective Altruism + - [ ] God Help Us, Let's Try To Understand AI Monosemanticity + - [ ] Open Thread 304 +- [ ] The Pragmatic Engineer: Holiday Season Gift Ideas for Techies +- [ ] Golang Weekly: 🥶 Like me, Go 1.22 is now frozen +- [ ] Linus Ekenstam at Inside My Head: Enhance/upscale anything - Magnific AI +- [ ] Not Boring: Narrative Tug-of-War +- [ ] Colin Wright: One Sentence News / November 28, 2023 +- [ ] Lenny's Newsletter + - [ ] Lessons from going freemium: a decision that broke our business + - [ ] Billion dollar failures, and billion dollar success | Tom Conrad (Quibi, Pandora, Pets.com, Snap, Zero) +- [ ] Aeon+Psyche Daily + - [ ] When bereavement turns to activism + - [ ] Why training won’t solve implicit bias +- [ ] Etgar Keret from Alphabet Soup: Alternative Fun Facts: Friendship Baby +- [ ] Huddle Up: How Populous Became The Top Sports Architecture Firm In The World +- [ ] Write With AI: How To (Productively) Edit Your Writing With ChatGPT -// private func trackReadEvent() { -// guard let itemID = item?.unwrappedID ?? pdfItem?.itemID else { return } -// guard let slug = item?.unwrappedSlug ?? pdfItem?.slug else { return } -// guard let originalArticleURL = item?.unwrappedPageURLString ?? pdfItem?.originalArticleURL else { return } -// -// EventTracker.track( -// .linkRead( -// linkID: itemID, -// slug: slug, -// originalArticleURL: originalArticleURL -// ) -// ) -// } +## Read + +- [ ] Big brands keep dropping X over antisemitism; $75M loss, report estimates | Ars Technica +- [ ] My Hero [Comic]Geeks are Sexy Technology News +- [ ] 'Project DNA': How Japan's J1 League became a 'flair factory' for Europe's top clubs +- [ ] One Sentence News / November 27, 2023 +- [ ] Only 1 day left to claim your 30% discount on annual membership! + +## Highlights + +- [ ] Big brands keep dropping X over antisemitism; $75M loss, report estimates | Ars Technica + - [ ] Musk [responded](https://twitter.com/elonmusk/status/1728164110137725260) to an X user who [said](https://x.com/JohnnaCrider1/status/1728155588993970484?s=20) that some users were "mad" over lower ad revenue-sharing, saying there was "not much we can do if advertisers boycott or reduce spend on our platform." +- [ ] 'Project DNA': How Japan's J1 League became a 'flair factory' for Europe's top clubs + - [ ] communities are being bound to clubs to create a nascent cultural heritage. + - [ ] The FA and the J1 League brought prefectures, associations, clubs, coaches, universities and schools together to work for a common good. There is a Japanese concept – “ikigai” – which describes the sourcing of meaning or fulfilment from a purpose. The national team was the ikigai. + - [ ] That aim is governed by regulation. It is now mandatory for every club to run their own academy with at least Under-15 and Under-18 teams. There are limits on the number of foreigners on each squad. The first team starting XI must contain at least two homegrown players and one Under-21 player. + - [ ] Clubs are also rewarded for developing and playing academy graduates. The earlier the player moves, the more development they get in a different footballing culture and the more space it allows for another young player to replace them. + - [ ] But in 2016, the Japanese FA created “Project DNA”, an initiative that aimed to adjust and amend existing training methods to produce more rounded footballers. They sent coaches to European clubs, including those in the Premier League. They studied and they cherry-picked and they vowed that insularity should never rule because Japan would never have enough alone. + - [ ] Japan’s most successful recent exports (Mitoma at Brighton, Daichi Kamada at Eintracht Frankfurt, Takefusa Kubo at Real Sociedad) are fun, unpredictable, exciting attacking players. + +## Archived + +- [ ] Why Hunter Biden Asked to Testify Publicly in Impeachment Bid | TIME +""" + +struct BriefingSection { + let title: String + var items = [String]() } -struct BriefingView: View { - @EnvironmentObject var authenticator: Authenticator - @EnvironmentObject var dataService: DataService - @Environment(\.presentationMode) var presentationMode: Binding +@MainActor final class BriefingViewModel: ObservableObject { + @Published var sections = [BriefingSection]() - static let navBarHeight = 50.0 - let articleId: String + func load() async { + var currentSection: BriefingSection? + var sections = [BriefingSection]() + let lines = BRIEFING.components(separatedBy: .newlines) - @StateObject private var viewModel = BriefingViewModel() - @State private var showFontSizePopover = false - @State private var showTitleEdit = false - @State private var navBarVisibilityRatio = 1.0 - @State private var showDeleteConfirmation = false - - var removeLinkToolbarItem: some View { - Button( - action: { print("delete item action") }, - label: { - Image(systemName: "trash") + lines.forEach { line in + if line.starts(with: "## ") { + if let currentSection = currentSection { + sections.append(currentSection) + } + let title = line.replacingOccurrences(of: "## ", with: "") + currentSection = BriefingSection(title: title) + } else if line.isEmpty { + return + } else { + currentSection?.items.append(line) } - ) + } + if let currentSection = currentSection { + sections.append(currentSection) + } + self.sections = sections } +} + +@MainActor +struct BriefingView: View { + @StateObject private var viewModel = BriefingViewModel() var body: some View { - ZStack { // Using ZStack so .task can be used on if/else body - if let item = viewModel.item { - WebReaderContainerView(item: item, pop: {}) - } - } - .task { - await viewModel.loadItem(articleId: articleId, dataService: dataService) - NotificationCenter.default.post(Notification(name: Notification.Name("ReaderSettingsChanged"))) -// static var readerSettingsChangedPublisher: NotificationCenter.Publisher { -// NotificationCenter.default.publisher(for: ReaderSettingsChanged) -// } - } - #if os(iOS) - .navigationBarHidden(true) - #endif - } - - var navBar: some View { - HStack(alignment: .center) { - Spacer() - Button( - action: { showFontSizePopover.toggle() }, - label: { - Image(systemName: "textformat.size") - .font(.appTitleTwo) - } - ) - .padding(.horizontal) - .scaleEffect(navBarVisibilityRatio) - Menu( - content: { - Group { - Button( - action: { showTitleEdit = true }, - label: { Label("Edit Info", systemImage: "info.circle") } - ) -// Button( -// action: { viewModel.handleArchiveAction(dataService: dataService) }, -// label: { -// Label( -// viewModel.isItemArchived ? "Unarchive" : "Archive", -// systemImage: viewModel.isItemArchived ? "tray.and.arrow.down.fill" : "archivebox" -// ) -// } -// ) - Button( - action: { showDeleteConfirmation = true }, - label: { Label("Delete", systemImage: "trash") } - ) + List { + ForEach(viewModel.sections, id: \.title) { section in + Section(section.title) { + ForEach(section.items, id: \.self) { item in + if item.starts(with: "- [ ] ") { + let idx = item.index(item.startIndex, offsetBy: 6) + HStack { + Image(systemName: "square") + Text(item.suffix(from: idx)) + .lineLimit(2) + } + } else if item.starts(with: " - [ ] ") { + let idx = item.index(item.startIndex, offsetBy: 8) + HStack { + Text(" ") + Image(systemName: "square") + Text(item.suffix(from: idx)) + .lineLimit(2) + } + } } - }, - label: { - Image(systemName: "ellipsis") - .padding(.horizontal) - .scaleEffect(navBarVisibilityRatio) } - ) - } - .frame(height: readerViewNavBarHeight * navBarVisibilityRatio) - .opacity(navBarVisibilityRatio) - .background(Color.systemBackground) - .onTapGesture { - showFontSizePopover = false - } - .alert("Are you sure?", isPresented: $showDeleteConfirmation) { -// Button("Remove Link", role: .destructive) { -// viewModel.handleDeleteAction(dataService: dataService) -// } - Button(LocalText.cancelGeneric, role: .cancel, action: {}) - } - .sheet(isPresented: $showTitleEdit) { - if let item = viewModel.item { - LinkedItemMetadataEditView(item: item) } } + .listStyle(.plain) + .task { + await viewModel.load() + } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Highlights/NotebookViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Highlights/NotebookViewModel.swift index 759ee5d83..33db22c4f 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Highlights/NotebookViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Highlights/NotebookViewModel.swift @@ -25,7 +25,7 @@ struct NoteItemParams: Identifiable { @Published var highlightItems = [HighlightListItemParams]() func load(itemObjectID: NSManagedObjectID, dataService: DataService) { - if let linkedItem = dataService.viewContext.object(with: itemObjectID) as? LinkedItem { + if let linkedItem = dataService.viewContext.object(with: itemObjectID) as? Models.LibraryItem { loadHighlights(item: linkedItem) } } @@ -53,7 +53,7 @@ struct NoteItemParams: Identifiable { let highlightId = UUID().uuidString.lowercased() let shortId = NanoID.generate(alphabet: NanoID.Alphabet.urlSafe.rawValue, size: 8) - if let linkedItem = dataService.viewContext.object(with: itemObjectID) as? LinkedItem { + if let linkedItem = dataService.viewContext.object(with: itemObjectID) as? Models.LibraryItem { noteItem = NoteItemParams(highlightID: highlightId, annotation: annotation) let highlight = dataService.createNote(shortId: shortId, highlightID: highlightId, @@ -105,7 +105,7 @@ struct NoteItemParams: Identifiable { highlightItems.map { highlightAsMarkdown(item: $0) }.lazy.joined(separator: "\n\n") } - private func loadHighlights(item: LinkedItem) { + private func loadHighlights(item: Models.LibraryItem) { let unsortedHighlights = item.highlights.asArray(of: Highlight.self) .filter { $0.type == "HIGHLIGHT" && $0.serverSyncStatus != ServerSyncStatus.needsDeletion.rawValue } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift index ad02df3d8..8714d9b52 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift @@ -1,13 +1,14 @@ import Models import Services import SwiftUI +import Transmission import Views struct MacFeedCardNavigationLink: View { @EnvironmentObject var dataService: DataService @EnvironmentObject var audioController: AudioController - let item: LinkedItem + let item: Models.LibraryItem @ObservedObject var viewModel: HomeFeedViewModel @@ -31,19 +32,50 @@ struct FeedCardNavigationLink: View { @EnvironmentObject var dataService: DataService @EnvironmentObject var audioController: AudioController - let item: LinkedItem + let item: Models.LibraryItem let isInMultiSelectMode: Bool @ObservedObject var viewModel: HomeFeedViewModel var body: some View { ZStack { LibraryItemCard(item: item, viewer: dataService.currentViewer) - NavigationLink(destination: LinkItemDetailView( - linkedItemObjectID: item.objectID, - isPDF: item.isPDF - ), label: { - EmptyView() - }).opacity(0) +// PresentationLink({ +// <#code#> +// } label: { +// EmptyView() +// }).opacity(0) + +// public init( +// edge: Edge = .bottom, +// prefersScaleEffect: Bool = true, +// preferredCornerRadius: CGFloat? = nil, +// isInteractive: Bool = true, +// options: Options = .init(modalPresentationCapturesStatusBarAppearance: true) +// ) { +// self.edge = edge +// self.prefersScaleEffect = prefersScaleEffect +// self.preferredCornerRadius = preferredCornerRadius +// self.isInteractive = isInteractive +// self.options = options +// } +// + PresentationLink( + transition: PresentationLinkTransition.slide( + options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing, + options: + PresentationLinkTransition.Options( + modalPresentationCapturesStatusBarAppearance: true + ))), + destination: { + LinkItemDetailView( + linkedItemObjectID: item.objectID, + isPDF: item.isPDF + ) + .background(ThemeManager.currentBgColor) + }, label: { + EmptyView() + } + ) } .onAppear { Task { await viewModel.itemAppeared(item: item, dataService: dataService) } @@ -57,7 +89,7 @@ struct GridCardNavigationLink: View { @State private var scale = 1.0 - let item: LinkedItem + let item: Models.LibraryItem let actionHandler: (GridCardAction) -> Void @Binding var isContextMenuOpen: Bool @@ -65,12 +97,28 @@ struct GridCardNavigationLink: View { @ObservedObject var viewModel: HomeFeedViewModel var body: some View { - NavigationLink(destination: LinkItemDetailView( - linkedItemObjectID: item.objectID, - isPDF: item.isPDF - )) { - GridCard(item: item, isContextMenuOpen: $isContextMenuOpen, actionHandler: actionHandler) - } + PresentationLink( + transition: PresentationLinkTransition.slide( + options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing, + options: + PresentationLinkTransition.Options( + modalPresentationCapturesStatusBarAppearance: true + ))), + destination: { + LinkItemDetailView( + linkedItemObjectID: item.objectID, + isPDF: item.isPDF + ) + }, label: { + GridCard(item: item, isContextMenuOpen: $isContextMenuOpen, actionHandler: actionHandler) + } + ) +// NavigationLink(destination: LinkItemDetailView( +// linkedItemObjectID: item.objectID, +// isPDF: item.isPDF +// )) { +// +// } .onAppear { Task { await viewModel.itemAppeared(item: item, dataService: dataService) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FollowingFetcher.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FollowingFetcher.swift new file mode 100644 index 000000000..d7ad556da --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FollowingFetcher.swift @@ -0,0 +1,257 @@ +//// +//// FollowingFetcher.swift +//// +//// +//// Created by Jackson Harper on 11/16/23. +//// +// +// import Foundation +// +// import CoreData +// import Models +// import Services +// import SwiftUI +// import Utils +// import Views +// +// @MainActor final class FollowingFetcher: NSObject, ObservableObject, LibraryItemFetcher { +// var folder = "following" +// +// @Published var items = [Models.LibraryItem]() +// var itemsPublisher: Published<[Models.LibraryItem]>.Publisher { $items } +// +// private var fetchedResultsController: NSFetchedResultsController? +// +// var cursor: String? +// +// // These are used to make sure we handle search result +// // responses in the right order +// var searchIdx = 0 +// var receivedIdx = 0 +// +// var syncCursor: String? +// +// func setItems(_: NSManagedObjectContext, _ items: [Models.LibraryItem]) { +// self.items = items +// } +// +// func loadCurrentViewer(dataService: DataService) async { +// // Cache the viewer +// if dataService.currentViewer == nil { +// _ = try? await dataService.fetchViewer() +// } +// } +// +// func loadLabels(dataService: DataService) async { +// let fetchRequest: NSFetchRequest = LinkedItemLabel.fetchRequest() +// fetchRequest.fetchLimit = 1 +// +// if (try? dataService.viewContext.count(for: fetchRequest)) == 0 { +// _ = try? await dataService.labels() +// } +// } +// +// func syncItems(dataService: DataService) async { +// let syncStart = Date.now +// let lastSyncDate = dataService.lastItemSyncTime +// +// try? await dataService.syncOfflineItemsWithServerIfNeeded() +// +// let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate, +// cursor: nil) +// +// syncCursor = syncResult?.cursor +// if let syncResult = syncResult, syncResult.hasMore { +// dataService.syncLinkedItemsInBackground(since: lastSyncDate) { +// // do nothing +// } +// } else { +// dataService.lastItemSyncTime = syncStart +// } +// +// // If possible start prefetching new pages in the background +// if +// let itemIDs = syncResult?.updatedItemIDs, +// let username = dataService.currentViewer?.username, +// !itemIDs.isEmpty +// { +// Task.detached(priority: .background) { +// await dataService.prefetchPages(itemIDs: itemIDs, username: username) +// } +// } +// } +// +// func loadSearchQuery(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async { +// let thisSearchIdx = searchIdx +// searchIdx += 1 +// +// if thisSearchIdx > 0, thisSearchIdx <= receivedIdx { +// return +// } +// +// let queryResult = try? await dataService.loadLinkedItems( +// limit: 10, +// searchQuery: searchQuery(filterState), +// cursor: isRefresh ? nil : cursor +// ) +// +// let filter = LinkedItemFilter(rawValue: filterState.appliedFilter) +// +// if let queryResult = queryResult { +// let newItems: [Models.LibraryItem] = { +// var itemObjects = [Models.LibraryItem]() +// dataService.viewContext.performAndWait { +// itemObjects = queryResult.itemIDs.compactMap { dataService.viewContext.object(with: $0) as? Models.LibraryItem } +// } +// return itemObjects +// }() +// +// print("RESULTS OF SEARCH: ", newItems) +// +// if filterState.searchTerm.replacingOccurrences(of: " ", with: "").isEmpty, filter?.allowLocalFetch ?? false { +// updateFetchController(dataService: dataService, filterState: filterState) +// } else { +// // Don't use FRC for searching. Use server results directly. +// if fetchedResultsController != nil { +// fetchedResultsController = nil +// setItems(dataService.viewContext, []) +// } +// setItems(dataService.viewContext, isRefresh ? newItems : items + newItems) +// } +// +// receivedIdx = thisSearchIdx +// cursor = queryResult.cursor +//// if let username = dataService.currentViewer?.username { +//// await dataService.prefetchPages(itemIDs: newItems.map(\.unwrappedID), username: username) +//// } +// } else { +// updateFetchController(dataService: dataService, filterState: filterState) +// } +// } +// +// func loadItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async { +// await withTaskGroup(of: Void.self) { group in +// group.addTask { await self.loadCurrentViewer(dataService: dataService) } +// group.addTask { await self.loadLabels(dataService: dataService) } +// group.addTask { await self.syncItems(dataService: dataService) } +// group.addTask { await self.updateFetchController(dataService: dataService, filterState: filterState) } +// await group.waitForAll() +// } +// +// let filter = LinkedItemFilter(rawValue: filterState.appliedFilter) +// let shouldSearch = items.count < 1 || isRefresh && filter != LinkedItemFilter.downloaded +// if shouldSearch { +// await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: isRefresh) +// } else { +// updateFetchController(dataService: dataService, filterState: filterState) +// } +// } +// +// func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async { +// let filter = LinkedItemFilter(rawValue: filterState.appliedFilter) +// if filter != LinkedItemFilter.downloaded { +// await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: isRefresh) +// } +// } +// +// private func fetchRequest(_ filterState: FetcherFilterState) -> NSFetchRequest { +// let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() +// +// var subPredicates = [NSPredicate]() +// +// let folderPredicate = NSPredicate( +// format: "%K == %@", #keyPath(Models.LibraryItem.folder), folder +// ) +// subPredicates.append(folderPredicate) +// +// if !filterState.selectedLabels.isEmpty { +// var labelSubPredicates = [NSPredicate]() +// +// for label in filterState.selectedLabels { +// labelSubPredicates.append( +// NSPredicate(format: "SUBQUERY(labels, $label, $label.id == \"\(label.unwrappedID)\").@count > 0") +// ) +// } +// +// subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates)) +// } +// +// if !filterState.negatedLabels.isEmpty { +// var labelSubPredicates = [NSPredicate]() +// +// for label in filterState.negatedLabels { +// labelSubPredicates.append( +// NSPredicate(format: "SUBQUERY(labels, $label, $label.id == \"\(label.unwrappedID)\").@count == 0") +// ) +// } +// +// subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates)) +// } +// +// fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subPredicates) +// fetchRequest.sortDescriptors = (LinkedItemSort(rawValue: filterState.appliedSort) ?? .newest).sortDescriptors +// +// return fetchRequest +// } +// +// private func updateFetchController(dataService: DataService, filterState: FetcherFilterState) { +// fetchedResultsController = NSFetchedResultsController( +// fetchRequest: fetchRequest(filterState), +// managedObjectContext: dataService.viewContext, +// sectionNameKeyPath: nil, +// cacheName: nil +// ) +// +// guard let fetchedResultsController = fetchedResultsController else { +// return +// } +// +// fetchedResultsController.delegate = self +// try? fetchedResultsController.performFetch() +// setItems(dataService.viewContext, fetchedResultsController.fetchedObjects ?? []) +// } +// +// private func searchQuery(_ filterState: FetcherFilterState) -> String { +// let sort = LinkedItemSort(rawValue: filterState.appliedSort) ?? .newest +//// var query = sort.queryString +// +// var query = "in:following \(sort.queryString)" +//// if !queryContainsFilter(filterState), let filter = LinkedItemFilter(rawValue: filterState.appliedFilter) { +//// query = "\(filter.queryString) \(sort.queryString)" +//// } +// +// if !filterState.searchTerm.isEmpty { +// query.append(" \(filterState.searchTerm)") +// } +// +// if !filterState.selectedLabels.isEmpty { +// query.append(" label:") +// query.append(filterState.selectedLabels.compactMap { label in +// if let name = label.name { +// return "\"\(name)\"" +// } +// return nil +// }.joined(separator: ",")) +// } +// +// if !filterState.negatedLabels.isEmpty { +// query.append(" !label:") +// query.append(filterState.negatedLabels.compactMap { label in +// if let name = label.name { +// return "\"\(name)\"" +// } +// return nil +// }.joined(separator: ",")) +// } +// +// print("QUERY: `\(query)`") +// +// return query +// } +// } +// +// extension FollowingFetcher: NSFetchedResultsControllerDelegate { +// func controllerDidChangeContent(_ controller: NSFetchedResultsController) { +// setItems(controller.managedObjectContext, controller.fetchedObjects as? [Models.LibraryItem] ?? []) +// } +// } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/InboxFetcher.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/InboxFetcher.swift new file mode 100644 index 000000000..5394b2a8d --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/InboxFetcher.swift @@ -0,0 +1,276 @@ +// +// InboxFetcher.swift +// +// +// Created by Jackson Harper on 11/16/23. +// + +import Foundation + +import CoreData +import Models +import Services +import SwiftUI +import Utils +import Views + +@MainActor final class InboxFetcher: NSObject, ObservableObject, LibraryItemFetcher { + var folder = "inbox" + + @Published var items = [Models.LibraryItem]() + var itemsPublisher: Published<[Models.LibraryItem]>.Publisher { $items } + + private var fetchedResultsController: NSFetchedResultsController? + + var cursor: String? + + // These are used to make sure we handle search result + // responses in the right order + var searchIdx = 0 + var receivedIdx = 0 + + var syncCursor: String? + + func setItems(_: NSManagedObjectContext, _ items: [Models.LibraryItem]) { + self.items = items + } + + func loadCurrentViewer(dataService: DataService) async { + // Cache the viewer + if dataService.currentViewer == nil { + _ = try? await dataService.fetchViewer() + } + } + + func loadLabels(dataService: DataService) async { + let fetchRequest: NSFetchRequest = LinkedItemLabel.fetchRequest() + fetchRequest.fetchLimit = 1 + + if (try? dataService.viewContext.count(for: fetchRequest)) == 0 { + _ = try? await dataService.labels() + } + } + + func syncItems(dataService: DataService) async { + let syncStart = Date.now + let lastSyncDate = dataService.lastItemSyncTime + + try? await dataService.syncOfflineItemsWithServerIfNeeded() + + let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate, + cursor: nil) + + syncCursor = syncResult?.cursor + if let syncResult = syncResult, syncResult.hasMore { + dataService.syncLinkedItemsInBackground(since: lastSyncDate) { + // do nothing + } + } else { + dataService.lastItemSyncTime = syncStart + } + + // If possible start prefetching new pages in the background + if + let itemIDs = syncResult?.updatedItemIDs, + let username = dataService.currentViewer?.username, + !itemIDs.isEmpty + { + Task.detached(priority: .background) { + await dataService.prefetchPages(itemIDs: itemIDs, username: username) + } + } + } + + func loadSearchQuery(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async { + let thisSearchIdx = searchIdx + searchIdx += 1 + + if thisSearchIdx > 0, thisSearchIdx <= receivedIdx { + return + } + + let queryResult = try? await dataService.loadLinkedItems( + limit: 10, + searchQuery: searchQuery(filterState), + cursor: isRefresh ? nil : cursor + ) + + if let appliedFilter = filterState.appliedFilter, let queryResult = queryResult { + let newItems: [Models.LibraryItem] = { + var itemObjects = [Models.LibraryItem]() + dataService.viewContext.performAndWait { + itemObjects = queryResult.itemIDs.compactMap { dataService.viewContext.object(with: $0) as? Models.LibraryItem } + } + return itemObjects + }() + + if filterState.searchTerm.replacingOccurrences(of: " ", with: "").isEmpty, appliedFilter.allowLocalFetch { + updateFetchController(dataService: dataService, filterState: filterState) + } else { + // Don't use FRC for searching. Use server results directly. + if fetchedResultsController != nil { + fetchedResultsController = nil + setItems(dataService.viewContext, []) + } + setItems(dataService.viewContext, isRefresh ? newItems : items + newItems) + } + + receivedIdx = thisSearchIdx + cursor = queryResult.cursor + if let username = dataService.currentViewer?.username { + await dataService.prefetchPages(itemIDs: newItems.map(\.unwrappedID), username: username) + } + } else { + updateFetchController(dataService: dataService, filterState: filterState) + } + } + + func loadItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async { + await withTaskGroup(of: Void.self) { group in + group.addTask { await self.loadCurrentViewer(dataService: dataService) } + group.addTask { await self.loadLabels(dataService: dataService) } + group.addTask { await self.syncItems(dataService: dataService) } + group.addTask { await self.updateFetchController(dataService: dataService, filterState: filterState) } + await group.waitForAll() + } + + if let appliedFilter = filterState.appliedFilter { + let shouldRemoteSearch = items.count < 1 || isRefresh && appliedFilter.shouldRemoteSearch + if shouldRemoteSearch { + await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: isRefresh) + } else { + updateFetchController(dataService: dataService, filterState: filterState) + } + } + } + + func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async { + if let appliedFilter = filterState.appliedFilter, appliedFilter.shouldRemoteSearch { + await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: isRefresh) + } + } + + func loadFeatureItems(context: NSManagedObjectContext, predicate: NSPredicate, sort: NSSortDescriptor) async -> [Models.LibraryItem] { + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() + fetchRequest.fetchLimit = 25 + fetchRequest.predicate = predicate + fetchRequest.sortDescriptors = [sort] + + return (try? context.fetch(fetchRequest)) ?? [] + } + + private func fetchRequest(_ filterState: FetcherFilterState) -> NSFetchRequest { + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() + + var subPredicates = [NSPredicate]() + + let folderPredicate = NSPredicate( + format: "%K == %@", #keyPath(Models.LibraryItem.folder), folder + ) + subPredicates.append(folderPredicate) + + if let predicate = filterState.appliedFilter?.predicate { + subPredicates.append(predicate) + } + + if !filterState.selectedLabels.isEmpty { + var labelSubPredicates = [NSPredicate]() + + for label in filterState.selectedLabels { + labelSubPredicates.append( + NSPredicate(format: "SUBQUERY(labels, $label, $label.id == \"\(label.unwrappedID)\").@count > 0") + ) + } + + subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates)) + } + + if !filterState.negatedLabels.isEmpty { + var labelSubPredicates = [NSPredicate]() + + for label in filterState.negatedLabels { + labelSubPredicates.append( + NSPredicate(format: "SUBQUERY(labels, $label, $label.id == \"\(label.unwrappedID)\").@count == 0") + ) + } + + subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates)) + } + + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subPredicates) + fetchRequest.sortDescriptors = (LinkedItemSort(rawValue: filterState.appliedSort) ?? .newest).sortDescriptors + + return fetchRequest + } + + private func updateFetchController(dataService: DataService, filterState: FetcherFilterState) { + fetchedResultsController = NSFetchedResultsController( + fetchRequest: fetchRequest(filterState), + managedObjectContext: dataService.viewContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + guard let fetchedResultsController = fetchedResultsController else { + return + } + + fetchedResultsController.delegate = self + try? fetchedResultsController.performFetch() + setItems(dataService.viewContext, fetchedResultsController.fetchedObjects ?? []) + } + + private func queryContainsFilter(_ filterState: FetcherFilterState) -> Bool { + if filterState.searchTerm.contains("in:inbox") || + filterState.searchTerm.contains("in:all") || + filterState.searchTerm.contains("in:archive") + { + return true + } + + return false + } + + private func searchQuery(_ filterState: FetcherFilterState) -> String { + let sort = LinkedItemSort(rawValue: filterState.appliedSort) ?? .newest + var query = sort.queryString + + if !queryContainsFilter(filterState), let queryString = filterState.appliedFilter?.filter { + query = "\(queryString) \(sort.queryString)" + } + + if !filterState.searchTerm.isEmpty { + query.append(" \(filterState.searchTerm)") + } + + if !filterState.selectedLabels.isEmpty { + query.append(" label:") + query.append(filterState.selectedLabels.compactMap { label in + if let name = label.name { + return "\"\(name)\"" + } + return nil + }.joined(separator: ",")) + } + + if !filterState.negatedLabels.isEmpty { + query.append(" !label:") + query.append(filterState.negatedLabels.compactMap { label in + if let name = label.name { + return "\"\(name)\"" + } + return nil + }.joined(separator: ",")) + } + + print("QUERY: `\(query)`") + + return query + } +} + +extension InboxFetcher: NSFetchedResultsControllerDelegate { + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + setItems(controller.managedObjectContext, controller.fetchedObjects as? [Models.LibraryItem] ?? []) + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryFeatureCardNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryFeatureCardNavigationLink.swift index 78a8c7409..c107f4d10 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryFeatureCardNavigationLink.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryFeatureCardNavigationLink.swift @@ -14,7 +14,7 @@ struct LibraryFeatureCardNavigationLink: View { @EnvironmentObject var dataService: DataService @EnvironmentObject var audioController: AudioController - let item: LinkedItem + let item: Models.LibraryItem @ObservedObject var viewModel: HomeFeedViewModel @State var showFeatureActions = false diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/FilterSelectorView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/FilterSelectorView.swift new file mode 100644 index 000000000..7c5f4b75a --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/FilterSelectorView.swift @@ -0,0 +1,121 @@ + +// import Introspect +// import Models +// import Services +// import SwiftUI +// import Views +// +// @MainActor final class FilterSelectorViewModel: NSObject, ObservableObject { +// @Published var isLoading = false +// @Published var errorMessage: String = "" +// @Published var showErrorMessage: Bool = false +// +// func error(_ msg: String) { +// errorMessage = msg +// showErrorMessage = true +// isLoading = false +// } +// } +// +// struct FilterSelectorView: View { +// @ObservedObject var viewModel: HomeFeedViewModel +// @ObservedObject var filterViewModel = FilterByLabelsViewModel() +// @EnvironmentObject var dataService: DataService +// @Environment(\.dismiss) private var dismiss +// +// @State var showLabelsSheet = false +// +// init(viewModel: HomeFeedViewModel) { +// self.viewModel = viewModel +// } +// +// var body: some View { +// Group { +// #if os(iOS) +// List { +// innerBody +// } +// .listStyle(.grouped) +// #elseif os(macOS) +// List { +// innerBody +// } +// .listStyle(.plain) +// #endif +// } +// #if os(iOS) +// .navigationBarTitle("Library") +// .navigationBarTitleDisplayMode(.inline) +// .navigationBarItems(trailing: doneButton) +// #endif +// } +// +// private var innerBody: some View { +// Group { +// Section { +// ForEach(LinkedItemFilter.allCases, id: \.self) { filter in +// HStack { +// Text(filter.displayName) +// .foregroundColor(filterState.appliedFilter == filter.rawValue ? Color.blue : Color.appTextDefault) +// Spacer() +// if filterState.appliedFilter == filter.rawValue { +// Image(systemName: "checkmark") +// .foregroundColor(Color.blue) +// } +// } +// .contentShape(Rectangle()) +// .onTapGesture { +// filterState.appliedFilter = filter.rawValue +// } +// } +// } +// +// Section("Labels") { +// Button( +// action: { +// showLabelsSheet = true +// }, +// label: { +// HStack { +// Text("Select Labels (\(filterState.selectedLabels.count))") +// Spacer() +// Image(systemName: "chevron.right") +// } +// } +// ) +// } +// } +// .sheet(isPresented: $showLabelsSheet) { +// FilterByLabelsView( +// initiallySelected: filterState.selectedLabels, +// initiallyNegated: filterState.negatedLabels +// ) { +// self.filterState.selectedLabels = $0 +// self.filterState.negatedLabels = $1 +// } +// } +// .task { +// await filterViewModel.loadLabels( +// dataService: dataService, +// initiallySelectedLabels: filterState.selectedLabels, +// initiallyNegatedLabels: filterState.negatedLabels +// ) +// } +// } +// +// func isNegated(_ label: LinkedItemLabel) -> Bool { +// filterViewModel.negatedLabels.contains(where: { $0.id == label.id }) +// } +// +// func isSelected(_ label: LinkedItemLabel) -> Bool { +// filterViewModel.selectedLabels.contains(where: { $0.id == label.id }) +// } +// +// var doneButton: some View { +// Button( +// action: { dismiss() }, +// label: { Text("Done") } +// ) +// .disabled(viewModel.isLoading) +// } +// } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 0bfae8717..86f294e6f 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -43,19 +43,25 @@ struct AnimatingCellHeight: AnimatableModifier { @ObservedObject var viewModel: HomeFeedViewModel @State private var selection = Set() + @ObservedObject var filterState = FetcherFilterState( + appliedFilterName: UserDefaults.standard.string(forKey: UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) ?? + LinkedItemFilter.inbox.rawValue + ) func loadItems(isRefresh: Bool) { - Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) } + Task { await viewModel.loadItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh) } } var showFeatureCards: Bool { viewModel.listConfig.hasFeatureCards && !viewModel.hideFeatureSection && - viewModel.items.count > 0 && - viewModel.searchTerm.isEmpty && - viewModel.selectedLabels.isEmpty && - viewModel.negatedLabels.isEmpty && - viewModel.appliedFilterName == "inbox" + viewModel.fetcher.items.count > 0 && + filterState.searchTerm.isEmpty && + filterState.selectedLabels.isEmpty && + filterState.negatedLabels.isEmpty + // MERGE TODO + // && +// viewModel.appliedFilterName == "inbox" } var body: some View { @@ -66,30 +72,34 @@ struct AnimatingCellHeight: AnimatableModifier { isEditMode: $isEditMode, selection: $selection, viewModel: viewModel, + filterState: filterState, showFeatureCards: showFeatureCards ) .refreshable { loadItems(isRefresh: true) } - .onChange(of: viewModel.searchTerm) { _ in + .onChange(of: filterState.searchTerm) { _ in // Maybe we should debounce this, but // it feels like it works ok without loadItems(isRefresh: true) } - .onChange(of: viewModel.selectedLabels) { _ in + .onChange(of: filterState.selectedLabels) { _ in loadItems(isRefresh: true) } - .onChange(of: viewModel.negatedLabels) { _ in + .onChange(of: filterState.negatedLabels) { _ in loadItems(isRefresh: true) } - .onChange(of: viewModel.appliedFilter) { _ in + .onChange(of: filterState.appliedFilter) { _ in loadItems(isRefresh: true) } - .onChange(of: viewModel.appliedSort) { _ in + .onChange(of: filterState.appliedSort) { _ in loadItems(isRefresh: true) } - .sheet(item: $viewModel.itemUnderLabelEdit) { item in - ApplyLabelsView(mode: .item(item), onSave: nil) + .sheet(item: $viewModel.itemUnderLabelEdit) { _ in + NavigationView { + BriefingView() + } + // ApplyLabelsView(mode: .item(item), onSave: nil) } .sheet(item: $viewModel.itemUnderTitleEdit) { item in LinkedItemMetadataEditView(item: item) @@ -115,7 +125,7 @@ struct AnimatingCellHeight: AnimatableModifier { .onReceive(NotificationCenter.default.publisher(for: Notification.Name("PushJSONArticle"))) { notification in guard let jsonArticle = notification.userInfo?["article"] as? JSONArticle else { return } guard let objectID = dataService.persist(jsonArticle: jsonArticle) else { return } - guard let linkedItem = dataService.viewContext.object(with: objectID) as? LinkedItem else { return } + guard let linkedItem = dataService.viewContext.object(with: objectID) as? Models.LibraryItem else { return } viewModel.pushFeedItem(item: linkedItem) viewModel.selectedItem = linkedItem viewModel.linkIsActive = true @@ -125,10 +135,10 @@ struct AnimatingCellHeight: AnimatableModifier { if let deepLink = DeepLink.make(from: url) { switch deepLink { case let .search(query): - viewModel.searchTerm = query + filterState.searchTerm = query case let .savedSearch(named): if let filter = viewModel.findFilter(dataService, named: named) { - viewModel.appliedFilter = filter + filterState.appliedFilter = filter } case let .webAppLinkRequest(requestID): DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { @@ -153,7 +163,7 @@ struct AnimatingCellHeight: AnimatableModifier { } } .task { - if viewModel.items.isEmpty { + if viewModel.fetcher.items.isEmpty { loadItems(isRefresh: false) } } @@ -165,10 +175,9 @@ struct AnimatingCellHeight: AnimatableModifier { ToolbarItem(placement: .barLeading) { VStack(alignment: .leading) { let showDate = isListScrolled && !listTitle.isEmpty - if let title = viewModel.appliedFilter?.name { + if let title = filterState.appliedFilter?.name { Text(title) .font(Font.system(size: showDate ? 10 : 18, weight: .semibold)) - if showDate, prefersListLayout, isListScrolled || !showFeatureCards { Text(listTitle) .font(Font.system(size: 15, weight: .regular)) @@ -263,6 +272,7 @@ struct AnimatingCellHeight: AnimatableModifier { @Binding var isEditMode: EditMode @Binding var selection: Set @ObservedObject var viewModel: HomeFeedViewModel + @ObservedObject var filterState: FetcherFilterState let showFeatureCards: Bool @@ -292,18 +302,23 @@ struct AnimatingCellHeight: AnimatableModifier { isEditMode: $isEditMode, selection: $selection, viewModel: viewModel, + filterState: filterState, showFeatureCards: showFeatureCards ) } else { - HomeFeedGridView(viewModel: viewModel, isListScrolled: $isListScrolled) + HomeFeedGridView( + viewModel: viewModel, + filterState: filterState, + isListScrolled: $isListScrolled + ) } }.sheet(isPresented: $viewModel.showLabelsSheet) { FilterByLabelsView( - initiallySelected: viewModel.selectedLabels, - initiallyNegated: viewModel.negatedLabels + initiallySelected: filterState.selectedLabels, + initiallyNegated: filterState.negatedLabels ) { - self.viewModel.selectedLabels = $0 - self.viewModel.negatedLabels = $1 + self.filterState.selectedLabels = $0 + self.filterState.negatedLabels = $1 } } .popup(isPresented: $viewModel.showSnackbar) { @@ -344,6 +359,7 @@ struct AnimatingCellHeight: AnimatableModifier { @Binding var selection: Set @ObservedObject var viewModel: HomeFeedViewModel + @ObservedObject var filterState: FetcherFilterState let showFeatureCards: Bool @@ -351,20 +367,20 @@ struct AnimatingCellHeight: AnimatableModifier { GeometryReader { reader in ScrollView(.horizontal, showsIndicators: false) { HStack { - if viewModel.searchTerm.count > 0 { - TextChipButton.makeSearchFilterButton(title: viewModel.searchTerm) { - viewModel.searchTerm = "" + if filterState.searchTerm.count > 0 { + TextChipButton.makeSearchFilterButton(title: filterState.searchTerm) { + filterState.searchTerm = "" }.frame(maxWidth: reader.size.width * 0.66) } else { Menu( content: { ForEach(viewModel.filters) { filter in - Button(filter.name, action: { viewModel.appliedFilter = filter }) + Button(filter.name, action: { filterState.appliedFilter = filter }) } }, label: { TextChipButton.makeMenuButton( - title: viewModel.appliedFilter?.name ?? "-", + title: filterState.appliedFilter?.name ?? "-", color: .systemGray6 ) } @@ -373,25 +389,25 @@ struct AnimatingCellHeight: AnimatableModifier { Menu( content: { ForEach(LinkedItemSort.allCases, id: \.self) { sort in - Button(sort.displayName, action: { viewModel.appliedSort = sort.rawValue }) + Button(sort.displayName, action: { filterState.appliedSort = sort.rawValue }) } }, label: { TextChipButton.makeMenuButton( - title: LinkedItemSort(rawValue: viewModel.appliedSort)?.displayName ?? "Sort", + title: LinkedItemSort(rawValue: filterState.appliedSort)?.displayName ?? "Sort", color: .systemGray6 ) } ) TextChipButton.makeAddLabelButton(color: .systemGray6, onTap: { viewModel.showLabelsSheet = true }) - ForEach(viewModel.selectedLabels, id: \.self) { label in + ForEach(filterState.selectedLabels, id: \.self) { label in TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) { - viewModel.selectedLabels.removeAll { $0.id == label.id } + filterState.selectedLabels.removeAll { $0.id == label.id } } } - ForEach(viewModel.negatedLabels, id: \.self) { label in + ForEach(filterState.negatedLabels, id: \.self) { label in TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) { - viewModel.negatedLabels.removeAll { $0.id == label.id } + filterState.negatedLabels.removeAll { $0.id == label.id } } } Spacer() @@ -403,7 +419,7 @@ struct AnimatingCellHeight: AnimatableModifier { .dynamicTypeSize(.small ... .accessibility1) } - func menuItems(for item: LinkedItem) -> some View { + func menuItems(for item: Models.LibraryItem) -> some View { libraryItemMenu(dataService: dataService, viewModel: viewModel, item: item) } @@ -509,9 +525,9 @@ struct AnimatingCellHeight: AnimatableModifier { static func reduce(value _: inout CGPoint, nextValue _: () -> CGPoint) {} } - @State var topItem: LinkedItem? + @State var topItem: Models.LibraryItem? - func setTopItem(_ item: LinkedItem) { + func setTopItem(_ item: Models.LibraryItem) { if let date = item.savedAt, let daysAgo = Calendar.current.dateComponents([.day], from: date, to: Date()).day { if daysAgo < 1 { let formatter = DateFormatter() @@ -561,7 +577,7 @@ struct AnimatingCellHeight: AnimatableModifier { .listRowSeparator(.hidden, edges: .all) .listRowInsets(.init(top: 0, leading: horizontalInset, bottom: 0, trailing: horizontalInset)) - if let appliedFilter = viewModel.appliedFilter, + if let appliedFilter = filterState.appliedFilter, networkMonitor.status == .disconnected, !appliedFilter.allowLocalFetch { @@ -593,7 +609,7 @@ struct AnimatingCellHeight: AnimatableModifier { } } - ForEach(Array(viewModel.items.enumerated()), id: \.1.unwrappedID) { _, item in + ForEach(Array(viewModel.fetcher.items.enumerated()), id: \.1.unwrappedID) { _, item in FeedCardNavigationLink( item: item, isInMultiSelectMode: viewModel.isInMultiSelectMode, @@ -642,41 +658,7 @@ struct AnimatingCellHeight: AnimatableModifier { } } - func dateSummaryCard(_: Date) -> some View { - VStack(alignment: .center, spacing: 15) { - Text("3 articles saved today") - .frame(maxWidth: .infinity, alignment: .center) - .font(.body) - HStack { - Spacer() - HStack(spacing: 0) { - Button(action: {}, label: { - Text("Archive all") - .font(Font.system(size: 14)) - .padding(.horizontal, 10) - }) - .frame(height: 30) - .background(Color.blue) - Button(action: {}, label: { - Image(systemName: "chevron.down") - .resizable() - .scaledToFit() - .frame(width: 10, height: 10) - .padding(.leading, 7.5) - .padding(.trailing, 7.5) - .foregroundColor(Color.white) - }) - .frame(height: 30) - .background(Color(hex: "345BB8")) - } - .cornerRadius(2.5) - Spacer() - } - } - .padding(15) - } - - func swipeActionButton(action: SwipeAction, item: LinkedItem) -> AnyView { + func swipeActionButton(action: SwipeAction, item: Models.LibraryItem) -> AnyView { switch action { case .pin: let isPinned = item.labels?.allObjects.first { ($0 as? LinkedItemLabel)?.name == "Pinned" } != nil @@ -717,7 +699,8 @@ struct AnimatingCellHeight: AnimatableModifier { // viewModel.addLabel(dataService: dataService, item: item, label: "Inbox", color) }, label: { - Label("Move to Inbox", systemImage: "tray.fill") + Label(title: { Text("Move to Library") }, + icon: { Image.tabLibrary }) } ).tint(Color(hex: "#0A84FF"))) } @@ -731,9 +714,11 @@ struct AnimatingCellHeight: AnimatableModifier { @State var isContextMenuOpen = false @ObservedObject var viewModel: HomeFeedViewModel + @ObservedObject var filterState: FetcherFilterState + @Binding var isListScrolled: Bool - func contextMenuActionHandler(item: LinkedItem, action: GridCardAction) { + func contextMenuActionHandler(item: Models.LibraryItem, action: GridCardAction) { switch action { case .viewHighlights: viewModel.itemForHighlightsView = item @@ -749,27 +734,27 @@ struct AnimatingCellHeight: AnimatableModifier { } func loadItems(isRefresh: Bool) { - Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) } + Task { await viewModel.loadItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh) } } var filtersHeader: some View { GeometryReader { reader in ScrollView(.horizontal, showsIndicators: false) { HStack { - if viewModel.searchTerm.count > 0 { - TextChipButton.makeSearchFilterButton(title: viewModel.searchTerm) { - viewModel.searchTerm = "" + if filterState.searchTerm.count > 0 { + TextChipButton.makeSearchFilterButton(title: filterState.searchTerm) { + filterState.searchTerm = "" }.frame(maxWidth: reader.size.width * 0.66) } else { Menu( content: { ForEach(viewModel.filters, id: \.self) { filter in - Button(filter.name, action: { viewModel.appliedFilter = filter }) + Button(filter.name, action: { filterState.appliedFilter = filter }) } }, label: { TextChipButton.makeMenuButton( - title: viewModel.appliedFilter?.name ?? "-", + title: filterState.appliedFilter?.name ?? "-", color: .systemGray6 ) } @@ -778,25 +763,25 @@ struct AnimatingCellHeight: AnimatableModifier { Menu( content: { ForEach(LinkedItemSort.allCases, id: \.self) { sort in - Button(sort.displayName, action: { viewModel.appliedSort = sort.rawValue }) + Button(sort.displayName, action: { filterState.appliedSort = sort.rawValue }) } }, label: { TextChipButton.makeMenuButton( - title: LinkedItemSort(rawValue: viewModel.appliedSort)?.displayName ?? "Sort", + title: LinkedItemSort(rawValue: filterState.appliedSort)?.displayName ?? "Sort", color: .systemGray6 ) } ) TextChipButton.makeAddLabelButton(color: .systemGray6, onTap: { viewModel.showLabelsSheet = true }) - ForEach(viewModel.selectedLabels, id: \.self) { label in + ForEach(filterState.selectedLabels, id: \.self) { label in TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) { - viewModel.selectedLabels.removeAll { $0.id == label.id } + filterState.selectedLabels.removeAll { $0.id == label.id } } } - ForEach(viewModel.negatedLabels, id: \.self) { label in + ForEach(filterState.negatedLabels, id: \.self) { label in TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) { - viewModel.negatedLabels.removeAll { $0.id == label.id } + filterState.negatedLabels.removeAll { $0.id == label.id } } } Spacer() @@ -832,7 +817,7 @@ struct AnimatingCellHeight: AnimatableModifier { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 325, maximum: 400), spacing: 16)], alignment: .center, spacing: 30) { - ForEach(viewModel.items) { item in + ForEach(viewModel.fetcher.items) { item in GridCardNavigationLink( item: item, actionHandler: { contextMenuActionHandler(item: item, action: $0) }, @@ -860,7 +845,7 @@ struct AnimatingCellHeight: AnimatableModifier { } } - if viewModel.items.isEmpty, viewModel.isLoading { + if viewModel.fetcher.items.isEmpty, viewModel.isLoading { LoadingSection() } } @@ -896,7 +881,7 @@ struct ScrollViewOffsetPreferenceKey: PreferenceKey { #endif struct LinkDestination: View { - let selectedItem: LinkedItem? + let selectedItem: Models.LibraryItem? var body: some View { Group { diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift index e96127e08..23614150f 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift @@ -11,7 +11,7 @@ import Views @EnvironmentObject var audioController: AudioController @EnvironmentObject var authenticator: Authenticator - @State private var itemToRemove: LinkedItem? + @State private var itemToRemove: Models.LibraryItem? @State private var confirmationShown = false @State private var presentProfileSheet = false @State private var addLinkPresented = false @@ -29,7 +29,7 @@ import Views } } - func menuItems(_ item: LinkedItem) -> some View { + func menuItems(_ item: Models.LibraryItem) -> some View { Group { Button( action: { viewModel.itemUnderTitleEdit = item }, diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 66b7a52ae..a0dd184b2 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -5,35 +5,30 @@ import SwiftUI import Utils import Views -@MainActor final class HomeFeedViewModel: NSObject, ObservableObject { +@MainActor final class HomeFeedViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDelegate { var currentDetailViewModel: LinkItemDetailViewModel? - private var fetchedResultsController: NSFetchedResultsController? + private var fetchedResultsController: NSFetchedResultsController? - @Published var items = [LinkedItem]() @Published var isLoading = false @Published var showPushNotificationPrimer = false - @Published var itemUnderLabelEdit: LinkedItem? - @Published var itemUnderTitleEdit: LinkedItem? - @Published var itemForHighlightsView: LinkedItem? - @Published var searchTerm = "" - @Published var scopeSelection = 0 - @Published var selectedLabels = [LinkedItemLabel]() - @Published var negatedLabels = [LinkedItemLabel]() + @Published var itemUnderLabelEdit: Models.LibraryItem? + @Published var itemUnderTitleEdit: Models.LibraryItem? + @Published var itemForHighlightsView: Models.LibraryItem? @Published var snoozePresented = false @Published var itemToSnoozeID: String? @Published var linkRequest: LinkRequest? @Published var showLoadingBar = false @Published var isInMultiSelectMode = false - @Published var appliedSort = LinkedItemSort.newest.rawValue @Published var selectedLinkItem: NSManagedObjectID? // used by mac app only - @Published var selectedItem: LinkedItem? + @Published var selectedItem: Models.LibraryItem? @Published var linkIsActive = false @Published var showLabelsSheet = false @Published var showFiltersModal = false - @Published var featureItems = [LinkedItem]() + @Published var showCommunityModal = false + @Published var featureItems = [Models.LibraryItem]() @Published var listConfig: LibraryListConfig @@ -41,11 +36,6 @@ import Views @Published var snackbarOperation: SnackbarOperation? @Published var filters = [InternalFilter]() - @Published var appliedFilter: InternalFilter? { - didSet { - appliedFilterName = appliedFilter?.name.lowercased() ?? "inbox" - } - } var cursor: String? @@ -57,19 +47,16 @@ import Views var syncCursor: String? @AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false - @AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) var appliedFilterName = "inbox" @AppStorage(UserDefaultKey.lastSelectedFeaturedItemFilter.rawValue) var featureFilter = FeaturedItemFilter.continueReading.rawValue - init(listConfig: LibraryListConfig) { + let fetcher: LibraryItemFetcher + + init(fetcher: LibraryItemFetcher, listConfig: LibraryListConfig) { + self.fetcher = fetcher self.listConfig = listConfig super.init() } - func setItems(_ context: NSManagedObjectContext, _ items: [LinkedItem]) { - self.items = items - updateFeatureFilter(context: context, filter: FeaturedItemFilter(rawValue: featureFilter)) - } - func updateFeatureFilter(context: NSManagedObjectContext, filter: FeaturedItemFilter?) { if let filter = filter { Task { @@ -86,21 +73,22 @@ import Views } } - func itemAppeared(item: LinkedItem, dataService: DataService) async { + func itemAppeared(item: Models.LibraryItem, dataService _: DataService) async { if isLoading { return } - let itemIndex = items.firstIndex(where: { $0.id == item.id }) - let thresholdIndex = items.index(items.endIndex, offsetBy: -5) + let itemIndex = fetcher.items.firstIndex(where: { $0.id == item.id }) + let thresholdIndex = fetcher.items.index(fetcher.items.endIndex, offsetBy: -5) // Check if user has scrolled to the last five items in the list // Make sure we aren't currently loading though, as this would get triggered when the first set // of items are presented to the user. - if let itemIndex = itemIndex, itemIndex > thresholdIndex { - await loadMoreItems(dataService: dataService, isRefresh: false) - } +// if let itemIndex = itemIndex, itemIndex > thresholdIndex { +// await loadMoreItems(dataService: dataService, isRefresh: false) +// } } - func pushFeedItem(item: LinkedItem) { - items.insert(item, at: 0) + func pushFeedItem(item _: Models.LibraryItem) { + /// TODO: jackson + // fetcher.items.insert(item, at: 0) } func loadCurrentViewer(dataService: DataService) async { @@ -139,110 +127,19 @@ import Views } } - func updateFilters(newFilters: [InternalFilter]) { - filters = newFilters.sorted(by: { $0.position < $1.position }) + [InternalFilter.DeletedFilter, InternalFilter.DownloadedFilter] - if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != appliedFilter?.id { - appliedFilter = newFilter - } + func updateFilters(newFilters _: [InternalFilter]) { +// filters = newFilters.sorted(by: { $0.position < $1.position }) + [InternalFilter.DeletedFilter, InternalFilter.DownloadedFilter] +// if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != appliedFilter?.id { +// appliedFilter = newFilter +// } } - func syncItems(dataService: DataService) async { - let syncStart = Date.now - let lastSyncDate = dataService.lastItemSyncTime - - try? await dataService.syncOfflineItemsWithServerIfNeeded() - - let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate, - cursor: nil) - - syncCursor = syncResult?.cursor - if let syncResult = syncResult, syncResult.hasMore { - dataService.syncLinkedItemsInBackground(since: lastSyncDate) { - // Set isLoading to false here - self.isLoading = false - } - } else { - dataService.lastItemSyncTime = syncStart - } - - // If possible start prefetching new pages in the background - if - let itemIDs = syncResult?.updatedItemIDs, - let username = dataService.currentViewer?.username, - !itemIDs.isEmpty - { - Task.detached(priority: .background) { - await dataService.prefetchPages(itemIDs: itemIDs, username: username) - } - } - } - - func loadSearchQuery(dataService: DataService, isRefresh: Bool) async { - let thisSearchIdx = searchIdx - searchIdx += 1 - - if thisSearchIdx > 0, thisSearchIdx <= receivedIdx { - return - } - - let queryResult = try? await dataService.loadLinkedItems( - limit: 10, - searchQuery: searchQuery, - cursor: isRefresh ? nil : cursor - ) - - if let appliedFilter = appliedFilter, let queryResult = queryResult { - let newItems: [LinkedItem] = { - var itemObjects = [LinkedItem]() - dataService.viewContext.performAndWait { - itemObjects = queryResult.itemIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItem } - } - return itemObjects - }() - - if searchTerm.replacingOccurrences(of: " ", with: "").isEmpty, appliedFilter.predicate != nil { - updateFetchController(dataService: dataService) - } else { - // Don't use FRC for searching. Use server results directly. - if fetchedResultsController != nil { - fetchedResultsController = nil - setItems(dataService.viewContext, []) - } - setItems(dataService.viewContext, isRefresh ? newItems : items + newItems) - } - - isLoading = false - receivedIdx = thisSearchIdx - cursor = queryResult.cursor - if let username = dataService.currentViewer?.username { - await dataService.prefetchPages(itemIDs: newItems.map(\.unwrappedID), username: username) - } - } else { - updateFetchController(dataService: dataService) - } - } - - func loadItems(dataService: DataService, isRefresh: Bool) async { + func loadItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async { isLoading = true showLoadingBar = true - await withTaskGroup(of: Void.self) { group in - group.addTask { await self.loadCurrentViewer(dataService: dataService) } - group.addTask { await self.loadLabels(dataService: dataService) } - group.addTask { await self.loadFilters(dataService: dataService) } - group.addTask { await self.syncItems(dataService: dataService) } - group.addTask { await self.updateFetchController(dataService: dataService) } - await group.waitForAll() - } - - if let appliedFilter = appliedFilter { - let shouldRemoteSearch = items.count < 1 || isRefresh && appliedFilter.shouldRemoteSearch - if shouldRemoteSearch { - await loadSearchQuery(dataService: dataService, isRefresh: isRefresh) - } else { - updateFetchController(dataService: dataService) - } - } + // group.addTask { await self.loadFilters(dataService: dataService) } + await fetcher.loadItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh) updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter)) @@ -250,20 +147,18 @@ import Views showLoadingBar = false } - func loadMoreItems(dataService: DataService, isRefresh: Bool) async { + func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async { isLoading = true showLoadingBar = true - if let appliedFilter, appliedFilter.shouldRemoteSearch { - await loadSearchQuery(dataService: dataService, isRefresh: isRefresh) - } + await fetcher.loadMoreItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh) isLoading = false showLoadingBar = false } - func loadFeatureItems(context: NSManagedObjectContext, predicate: NSPredicate, sort: NSSortDescriptor) async -> [LinkedItem] { - let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() + func loadFeatureItems(context: NSManagedObjectContext, predicate: NSPredicate, sort: NSSortDescriptor) async -> [Models.LibraryItem] { + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() fetchRequest.fetchLimit = 25 fetchRequest.predicate = predicate fetchRequest.sortDescriptors = [sort] @@ -271,62 +166,6 @@ import Views return (try? context.fetch(fetchRequest)) ?? [] } - private var fetchRequest: NSFetchRequest { - let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() - - var subPredicates = [NSPredicate]() - - if let predicate = appliedFilter?.predicate { - subPredicates.append(predicate) - } - - if !selectedLabels.isEmpty { - var labelSubPredicates = [NSPredicate]() - - for label in selectedLabels { - labelSubPredicates.append( - NSPredicate(format: "SUBQUERY(labels, $label, $label.id == \"\(label.unwrappedID)\").@count > 0") - ) - } - - subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates)) - } - - if !negatedLabels.isEmpty { - var labelSubPredicates = [NSPredicate]() - - for label in negatedLabels { - labelSubPredicates.append( - NSPredicate(format: "SUBQUERY(labels, $label, $label.id == \"\(label.unwrappedID)\").@count == 0") - ) - } - - subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates)) - } - - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subPredicates) - fetchRequest.sortDescriptors = (LinkedItemSort(rawValue: appliedSort) ?? .newest).sortDescriptors - - return fetchRequest - } - - private func updateFetchController(dataService: DataService) { - fetchedResultsController = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: dataService.viewContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - - guard let fetchedResultsController = fetchedResultsController else { - return - } - - fetchedResultsController.delegate = self - try? fetchedResultsController.performFetch() - setItems(dataService.viewContext, fetchedResultsController.fetchedObjects ?? []) - } - func snackbar(_ message: String, undoAction: SnackbarUndoAction? = nil) { snackbarOperation = SnackbarOperation(message: message, undoAction: undoAction) showSnackbar = true @@ -362,7 +201,7 @@ import Views return nil } - func addLabel(dataService: DataService, item: LinkedItem, label: String, color: String) { + func addLabel(dataService: DataService, item: Models.LibraryItem, label: String, color: String) { if let label = getOrCreateLabel(dataService: dataService, named: "Pinned", color: color) { let existingLabels = item.labels?.allObjects.compactMap { $0 as? LinkedItemLabel } ?? [] dataService.setItemLabels(itemID: item.unwrappedID, labels: InternalLinkedItemLabel.make(Set(existingLabels + [label]) as NSSet)) @@ -372,7 +211,7 @@ import Views } } - func removeLabel(dataService: DataService, item: LinkedItem, named: String) { + func removeLabel(dataService: DataService, item: Models.LibraryItem, named: String) { let labels = item.labels? .filter { ($0 as? LinkedItemLabel)?.name != named } .compactMap { $0 as? LinkedItemLabel } ?? [] @@ -380,25 +219,25 @@ import Views item.update(inContext: dataService.viewContext) } - func pinItem(dataService: DataService, item: LinkedItem) { + func pinItem(dataService: DataService, item: Models.LibraryItem) { addLabel(dataService: dataService, item: item, label: "Pinned", color: "#0A84FF") if featureFilter == FeaturedItemFilter.pinned.rawValue { updateFeatureFilter(context: dataService.viewContext, filter: .pinned) } } - func unpinItem(dataService: DataService, item: LinkedItem) { + func unpinItem(dataService: DataService, item: Models.LibraryItem) { removeLabel(dataService: dataService, item: item, named: "Pinned") if featureFilter == FeaturedItemFilter.pinned.rawValue { updateFeatureFilter(context: dataService.viewContext, filter: .pinned) } } - func markRead(dataService: DataService, item: LinkedItem) { + func markRead(dataService: DataService, item: Models.LibraryItem) { dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 100, anchorIndex: 0, force: true) } - func markUnread(dataService: DataService, item: LinkedItem) { + func markUnread(dataService: DataService, item: Models.LibraryItem) { dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0, anchorIndex: 0, force: true) } @@ -420,55 +259,4 @@ import Views func findFilter(_: DataService, named: String) -> InternalFilter? { filters.first(where: { $0.name == named }) } - - private var queryContainsFilter: Bool { - if searchTerm.contains("in:inbox") || searchTerm.contains("in:all") || searchTerm.contains("in:archive") { - return true - } - - return false - } - - private var searchQuery: String { - let sort = LinkedItemSort(rawValue: appliedSort) ?? .newest - var query = sort.queryString - - if !queryContainsFilter, let filter = appliedFilter?.filter { - query = "\(filter) \(sort.queryString)" - } - - if !searchTerm.isEmpty { - query.append(" \(searchTerm)") - } - - if !selectedLabels.isEmpty { - query.append(" label:") - query.append(selectedLabels.compactMap { label in - if let name = label.name { - return "\"\(name)\"" - } - return nil - }.joined(separator: ",")) - } - - if !negatedLabels.isEmpty { - query.append(" !label:") - query.append(negatedLabels.compactMap { label in - if let name = label.name { - return "\"\(name)\"" - } - return nil - }.joined(separator: ",")) - } - - print("QUERY: `\(query)`") - - return query - } -} - -extension HomeFeedViewModel: NSFetchedResultsControllerDelegate { - func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - setItems(controller.managedObjectContext, controller.fetchedObjects as? [LinkedItem] ?? []) - } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/LibraryItemFetcher.swift b/apple/OmnivoreKit/Sources/App/Views/Home/LibraryItemFetcher.swift new file mode 100644 index 000000000..44ea54c1e --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/LibraryItemFetcher.swift @@ -0,0 +1,43 @@ +// +// File.swift +// +// +// Created by Jackson Harper on 11/16/23. +// + +import Foundation +import Models +import Services +import SwiftUI +import Utils + +@MainActor +class FetcherFilterState: ObservableObject { + @Published var searchTerm = "" + @Published var selectedLabels = [LinkedItemLabel]() + @Published var negatedLabels = [LinkedItemLabel]() + + @Published var appliedSort = LinkedItemSort.newest.rawValue + + @AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) var appliedFilterName = "inbox" + @Published var appliedFilter: InternalFilter? { + didSet { + appliedFilterName = appliedFilter?.name.lowercased() ?? "inbox" + } + } + + init(appliedFilterName: String) { + self.appliedFilterName = appliedFilterName + } +} + +@MainActor +protocol LibraryItemFetcher { + var folder: String { get } + + var items: [Models.LibraryItem] { get } + var itemsPublisher: Published<[Models.LibraryItem]>.Publisher { get } + + func loadItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async + func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/LibraryItemMenu.swift b/apple/OmnivoreKit/Sources/App/Views/Home/LibraryItemMenu.swift index 2c06ce60b..b91ffdd46 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/LibraryItemMenu.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/LibraryItemMenu.swift @@ -6,7 +6,7 @@ import UserNotifications import Utils import Views -@MainActor func libraryItemMenu(dataService: DataService, viewModel: HomeFeedViewModel, item: LinkedItem) -> some View { +@MainActor func libraryItemMenu(dataService: DataService, viewModel: HomeFeedViewModel, item: Models.LibraryItem) -> some View { Group { if item.state != "DELETED" { Button( @@ -17,33 +17,33 @@ import Views action: { viewModel.itemUnderLabelEdit = item }, label: { Label(item.labels?.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") } ) - Button(action: { - withAnimation(.linear(duration: 0.4)) { - viewModel.setLinkArchived( - dataService: dataService, - objectID: item.objectID, - archived: !item.isArchived - ) - } - }, label: { - Label( - item.isArchived ? "Unarchive" : "Archive", - systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox" - ) - }) - Button("Remove Item", role: .destructive) { - viewModel.removeLink(dataService: dataService, objectID: item.objectID) - } - if let author = item.author { - Button( - action: { - viewModel.searchTerm = "author:\"\(author)\"" - }, - label: { - Label(String("More by \(author)"), systemImage: "person") - } - ) - } +// Button(action: { +// withAnimation(.linear(duration: 0.4)) { +// viewModel.setLinkArchived( +// dataService: dataService, +// objectID: item.objectID, +// archived: !item.isArchived +// ) +// } +// }, label: { +// Label( +// item.isArchived ? "Unarchive" : "Archive", +// systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox" +// ) +// }) +// Button("Remove Item", role: .destructive) { +// viewModel.removeLink(dataService: dataService, objectID: item.objectID) +// } +// if let author = item.author { +// Button( +// action: { +// viewModel.filterState.searchTerm = "author:\"\(author)\"" +// }, +// label: { +// Label(String("More by \(author)"), systemImage: "person") +// } +// ) +// } } else { Button( action: { viewModel.recoverItem(dataService: dataService, itemID: item.unwrappedID) }, diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/LibraryListView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/LibraryListView.swift index bbf94692b..43c9da2f8 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/LibraryListView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/LibraryListView.swift @@ -10,16 +10,8 @@ import Models import SwiftUI struct LibraryListView: View { - @StateObject private var subViewModel = HomeFeedViewModel( - listConfig: LibraryListConfig( - hasFeatureCards: false, - leadingSwipeActions: [.moveToInbox], - trailingSwipeActions: [.archive, .delete], - cardStyle: .library - ) - ) - @StateObject private var libraryViewModel = HomeFeedViewModel( + fetcher: InboxFetcher(), listConfig: LibraryListConfig( hasFeatureCards: true, leadingSwipeActions: [.pin], @@ -28,15 +20,6 @@ struct LibraryListView: View { ) ) - @StateObject private var highlightsViewModel = HomeFeedViewModel( - listConfig: LibraryListConfig( - hasFeatureCards: true, - leadingSwipeActions: [.pin], - trailingSwipeActions: [.archive, .delete], - cardStyle: .highlights - ) - ) - var body: some View { // ZStack { // NavigationLink( diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchView.swift index 523867779..cd49f94fa 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchView.swift @@ -33,11 +33,11 @@ performTypeahead(searchTerm) } - func performSearch(_ searchTerm: String) { - let term = searchTerm.trimmingCharacters(in: Foundation.CharacterSet.whitespacesAndNewlines) - viewModel.saveRecentSearch(dataService: dataService, searchTerm: term) - recents = viewModel.recentSearches(dataService: dataService) - homeFeedViewModel.searchTerm = term + func performSearch(_: String) { +// let term = searchTerm.trimmingCharacters(in: Foundation.CharacterSet.whitespacesAndNewlines) +// viewModel.saveRecentSearch(dataService: dataService, searchTerm: term) +// recents = viewModel.recentSearches(dataService: dataService) +// homeFeedViewModel.searchTerm = term dismiss() } diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift index 409955170..8496a693e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift @@ -7,7 +7,7 @@ import Views @MainActor struct ApplyLabelsView: View { enum Mode { - case item(LinkedItem) + case item(Models.LibraryItem) case highlight(Highlight) case list([LinkedItemLabel]) diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift index 569014ab3..23ad0059c 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift @@ -25,7 +25,7 @@ import SwiftUI func loadLabels( dataService: DataService, - item: LinkedItem? = nil, + item: Models.LibraryItem? = nil, highlight: Highlight? = nil, initiallySelectedLabels: [LinkedItemLabel]? = nil ) async { diff --git a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift index 763bef4f6..6af409c87 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift @@ -16,7 +16,14 @@ import Views struct LibraryTabView: View { @EnvironmentObject var dataService: DataService - @StateObject private var subViewModel = HomeFeedViewModel( + @MainActor + public init() { + UITabBar.appearance().isHidden = true + UITabBar.appearance().backgroundColor = UIColor(Color.themeTabBarColor) + } + + @StateObject private var followingViewModel = HomeFeedViewModel( + fetcher: InboxFetcher(), listConfig: LibraryListConfig( hasFeatureCards: false, leadingSwipeActions: [.moveToInbox], @@ -26,6 +33,7 @@ struct LibraryTabView: View { ) @StateObject private var libraryViewModel = HomeFeedViewModel( + fetcher: InboxFetcher(), listConfig: LibraryListConfig( hasFeatureCards: true, leadingSwipeActions: [.pin], @@ -35,6 +43,7 @@ struct LibraryTabView: View { ) @StateObject private var highlightsViewModel = HomeFeedViewModel( + fetcher: InboxFetcher(), listConfig: LibraryListConfig( hasFeatureCards: true, leadingSwipeActions: [.pin], @@ -43,12 +52,31 @@ struct LibraryTabView: View { ) ) + @State var selectedTab = "following" + var body: some View { - NavigationView { - HomeView(viewModel: libraryViewModel) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif + VStack(spacing: 0) { + TabView(selection: $selectedTab) { + NavigationView { + HomeView(viewModel: followingViewModel) + .navigationViewStyle(.stack) + } + .tag("following") + + NavigationView { + HomeView(viewModel: libraryViewModel) + .navigationViewStyle(.stack) + } + .tag("inbox") + + NavigationView { + ProfileView() + .navigationViewStyle(.stack) + } + .tag("profile") + } + CustomTabBar(selectedTab: $selectedTab) } + .ignoresSafeArea() } } diff --git a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift index 393f87f46..8b0a1f010 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift @@ -7,11 +7,11 @@ import Views @MainActor final class LinkItemDetailViewModel: ObservableObject { @Published var pdfItem: PDFItem? - @Published var item: LinkedItem? + @Published var item: Models.LibraryItem? func loadItem(linkedItemObjectID: NSManagedObjectID, dataService: DataService) async { let item = await dataService.viewContext.perform { - dataService.viewContext.object(with: linkedItemObjectID) as? LinkedItem + dataService.viewContext.object(with: linkedItemObjectID) as? Models.LibraryItem } if let item = item { @@ -85,17 +85,11 @@ struct LinkItemDetailView: View { } var body: some View { - ZStack { + Group { if isPDF { pdfContainerView } else if let item = viewModel.item { WebReaderContainerView(item: item, pop: { dismiss() }) - #if os(iOS) - .navigationBarHidden(true) - .lazyPop(pop: { - dismiss() - }, isEnabled: $isEnabled) - #endif } } .ignoresSafeArea(.all, edges: .bottom) diff --git a/apple/OmnivoreKit/Sources/App/Views/LinkedItemMetadataEditView.swift b/apple/OmnivoreKit/Sources/App/Views/LinkedItemMetadataEditView.swift index 39065ef6d..26560de47 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LinkedItemMetadataEditView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LinkedItemMetadataEditView.swift @@ -8,13 +8,13 @@ import Views @Published var description = "" @Published var author = "" - func load(item: LinkedItem) { + func load(item: Models.LibraryItem) { title = item.unwrappedTitle author = item.author ?? "" description = item.descriptionText ?? "" } - func submit(dataService: DataService, item: LinkedItem) { + func submit(dataService: DataService, item: Models.LibraryItem) { dataService.updateLinkedItemTitleAndDescription( itemID: item.unwrappedID, title: title, @@ -30,10 +30,10 @@ struct LinkedItemMetadataEditView: View { @Environment(\.presentationMode) private var presentationMode @StateObject var viewModel = LinkedItemMetadataEditViewModel() - let item: LinkedItem + let item: Models.LibraryItem let onSave: ((String, String) -> Void)? - init(item: LinkedItem, onSave: ((String, String) -> Void)? = nil) { + init(item: Models.LibraryItem, onSave: ((String, String) -> Void)? = nil) { self.item = item self.onSave = onSave } diff --git a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift index a65b1def5..6ce2f22d4 100644 --- a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift @@ -18,11 +18,11 @@ import Views public var innerBody: some View { #if os(iOS) if UIDevice.isIPad { - return AnyView(splitView) + return AnyView(LibraryTabView()) + // return AnyView(splitView) } else { return AnyView( LibraryTabView() - .navigationViewStyle(.stack) ) } #else diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift index 7f8a331b2..4b32de72f 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift @@ -70,12 +70,7 @@ struct ProfileView: View { innerBody } .navigationTitle(LocalText.genericProfile) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - dismissButton - } - } + .navigationBarTitleDisplayMode(.large) #elseif os(macOS) List { innerBody diff --git a/apple/OmnivoreKit/Sources/App/Views/RemoveLibraryItemAction.swift b/apple/OmnivoreKit/Sources/App/Views/RemoveLibraryItemAction.swift index 60269a8e1..763b4b191 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RemoveLibraryItemAction.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RemoveLibraryItemAction.swift @@ -8,7 +8,7 @@ import Views func removeLibraryItemAction(dataService: DataService, objectID: NSManagedObjectID) { dataService.viewContext.performAndWait { - if let item = dataService.viewContext.object(with: objectID) as? LinkedItem { + if let item = dataService.viewContext.object(with: objectID) as? Models.LibraryItem { item.state = "DELETED" try? dataService.viewContext.save() @@ -37,7 +37,7 @@ func removeLibraryItemAction(dataService: DataService, objectID: NSManagedObject print("canceling task", syncTask) syncTask.cancel() dataService.viewContext.performAndWait { - if let item = dataService.viewContext.object(with: objectID) as? LinkedItem { + if let item = dataService.viewContext.object(with: objectID) as? Models.LibraryItem { item.state = "SUCCEEDED" try? dataService.viewContext.save() } diff --git a/apple/OmnivoreKit/Sources/App/Views/TabBar/CustomTabBar.swift b/apple/OmnivoreKit/Sources/App/Views/TabBar/CustomTabBar.swift new file mode 100644 index 000000000..28c39586e --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/TabBar/CustomTabBar.swift @@ -0,0 +1,41 @@ +import Foundation +import SwiftUI + +struct CustomTabBar: View { + @Binding var selectedTab: String + @Namespace var animation + var body: some View { + HStack(spacing: 0) { + TabBarButton(key: "following", image: Image.tabFollowing, selectedTab: $selectedTab, animation: animation) + TabBarButton(key: "inbox", image: Image.tabLibrary, selectedTab: $selectedTab, animation: animation) + TabBarButton(key: "profile", image: Image.tabHighlights, selectedTab: $selectedTab, animation: animation) + } + .padding(.top, 10) + .padding(.bottom, 40) + .background(Color.themeTabBarColor) + } +} + +struct TabBarButton: View { + let key: String + let image: Image + @Binding var selectedTab: String + var animation: Namespace.ID + + var body: some View { + Button(action: { + withAnimation(.spring()) { + selectedTab = key + } + }, label: { + image + .resizable() + .renderingMode(.template) + .aspectRatio(contentMode: .fit) + .frame(width: 28, height: 28) + .foregroundColor(selectedTab == key ? Color.blue : Color.themeTabButtonColor) + + .frame(maxWidth: .infinity) + }) + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift index 029a443e7..b45b610da 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift @@ -6,7 +6,7 @@ import WebKit @MainActor struct WebReader: PlatformViewRepresentable { - let item: LinkedItem + let item: Models.LibraryItem let viewModel: WebReaderViewModel let articleContent: ArticleContent let openLinkAction: (URL) -> Void diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 885b7bfd6..3e96ab26d 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -9,7 +9,7 @@ import WebKit // swiftlint:disable file_length type_body_length struct WebReaderContainerView: View { - let item: LinkedItem + let item: Models.LibraryItem let pop: () -> Void @State private var showPreferencesPopover = false @@ -209,7 +209,7 @@ struct WebReaderContainerView: View { ) } - func menuItems(for item: LinkedItem) -> some View { + func menuItems(for item: Models.LibraryItem) -> some View { let hasLabels = item.labels?.count != 0 return Group { Button( diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift index 6cb9956f0..84490173c 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift @@ -7,7 +7,7 @@ struct WebReaderContent { let textFontSize: Int let lineHeight: Int let maxWidthPercentage: Int - let item: LinkedItem + let item: LibraryItem let isDark: Bool let themeKey: String let fontFamily: WebFont @@ -17,7 +17,7 @@ struct WebReaderContent { let justifyText: Bool init( - item: LinkedItem, + item: Models.LibraryItem, articleContent: ArticleContent, isDark: Bool, fontSize: Int, diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift index 545968187..ae05dd6f0 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift @@ -6,7 +6,7 @@ import Utils import Views @MainActor final class WebReaderLoadingContainerViewModel: ObservableObject { - @Published var item: LinkedItem? + @Published var item: Models.LibraryItem? @Published var errorMessage: String? func loadItem(dataService: DataService, username: String, requestID: String) async { @@ -15,7 +15,7 @@ import Views else { return } - item = dataService.viewContext.object(with: objectID) as? LinkedItem + item = dataService.viewContext.object(with: objectID) as? Models.LibraryItem } func trackReadEvent() { @@ -38,7 +38,6 @@ public struct WebReaderLoadingContainer: View { @EnvironmentObject var dataService: DataService @EnvironmentObject var audioController: AudioController - @State var lazyPopIsEnabled = true @StateObject var viewModel = WebReaderLoadingContainerViewModel() public var body: some View { @@ -59,8 +58,6 @@ public struct WebReaderLoadingContainer: View { WebReaderContainerView(item: item, pop: { dismiss() }) #if os(iOS) .navigationViewStyle(.stack) - .navigationBarHidden(true) - .lazyPop(pop: { dismiss() }, isEnabled: $lazyPopIsEnabled) #endif .accentColor(.appGrayTextContrast) .task { viewModel.trackReadEvent() } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift index 2f29e5bc7..8301b90c0 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift @@ -24,7 +24,7 @@ struct SafariWebLink: Identifiable { showSnackbar = true } - func hasOriginalUrl(_ item: LinkedItem) -> Bool { + func hasOriginalUrl(_ item: Models.LibraryItem) -> Bool { if let pageURLString = item.pageURLString, let host = URL(string: pageURLString)?.host { if host == "omnivore.app" { return false @@ -34,7 +34,7 @@ struct SafariWebLink: Identifiable { return false } - func downloadAudio(audioController: AudioController, item: LinkedItem) { + func downloadAudio(audioController: AudioController, item: Models.LibraryItem) { snackbar(message: "Downloading Offline Audio") isDownloadingAudio = true diff --git a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index 5ab4d7d1d..d8adbc203 100644 --- a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -28,20 +28,21 @@ - + - + + @@ -86,7 +87,7 @@ - + @@ -113,7 +114,7 @@ - + diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift index ab370b541..7861f8575 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift @@ -47,6 +47,7 @@ public struct JSONArticle: Decodable { public let updatedAt: Date public let savedAt: Date public let readAt: Date? + public let folder: String public let image: String public let readingProgressPercent: Double public let readingProgressAnchorIndex: Int @@ -59,7 +60,7 @@ public struct JSONArticle: Decodable { public let downloadURL: String } -public extension LinkedItem { +public extension LibraryItem { var unwrappedID: String { id ?? "" } var unwrappedSlug: String { slug ?? "" } var unwrappedTitle: String { title ?? "" } @@ -247,13 +248,13 @@ public extension LinkedItem { ) } - static func lookup(byID itemID: String, inContext context: NSManagedObjectContext) -> LinkedItem? { - let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() + static func lookup(byID itemID: String, inContext context: NSManagedObjectContext) -> LibraryItem? { + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() fetchRequest.predicate = NSPredicate( format: "id == %@", itemID ) - var item: LinkedItem? + var item: LibraryItem? context.performAndWait { item = (try? context.fetch(fetchRequest))?.first diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/PDFItem.swift b/apple/OmnivoreKit/Sources/Models/DataModels/PDFItem.swift index 224e60901..3dcc5c39e 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/PDFItem.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/PDFItem.swift @@ -17,7 +17,7 @@ public struct PDFItem { public let downloadURL: String public let highlights: [Highlight] - public static func make(item: LinkedItem) -> PDFItem? { + public static func make(item: LibraryItem) -> PDFItem? { guard item.isPDF else { return nil } return PDFItem( diff --git a/apple/OmnivoreKit/Sources/Models/InboxFilters.swift b/apple/OmnivoreKit/Sources/Models/InboxFilters.swift index 123fdba6a..f35c1a3ed 100644 --- a/apple/OmnivoreKit/Sources/Models/InboxFilters.swift +++ b/apple/OmnivoreKit/Sources/Models/InboxFilters.swift @@ -94,92 +94,92 @@ import Foundation // } // } -public enum FeaturedItemFilter: String, CaseIterable { - case continueReading - case recommended - case newsletters - case pinned -} - -public extension FeaturedItemFilter { - var title: String { - switch self { - case .continueReading: - return "Continue Reading" - case .recommended: - return "Recommended" - case .newsletters: - return "Newsletters" - case .pinned: - return "Pinned" - } - } - - var emptyMessage: String { - switch self { - case .continueReading: - return "Your recently read items will appear here." - case .pinned: - return "Create a label named Pinned and add it to items you would like to appear here." - case .recommended: - return "Reads recommended in your Clubs will appear here." - case .newsletters: - return "All your Newsletters will appear here." - } - } - - var predicate: NSPredicate { - let undeletedPredicate = NSPredicate( - format: "%K != %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue) - ) - let notInArchivePredicate = NSPredicate( - format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber - ) - - switch self { - case .continueReading: - // Use > 1 instead of 0 so its only reads they have made slight progress on. - let continueReadingPredicate = NSPredicate( - format: "readingProgress > 1 AND readingProgress < 100 AND readAt != nil" - ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - continueReadingPredicate, undeletedPredicate, notInArchivePredicate - ]) - case .pinned: - let pinnedPredicate = NSPredicate( - format: "SUBQUERY(labels, $label, $label.name == \"Pinned\").@count > 0" - ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - notInArchivePredicate, undeletedPredicate, pinnedPredicate - ]) - case .newsletters: - // non-archived or deleted items with the Newsletter label - let newsletterLabelPredicate = NSPredicate( - format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0" - ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - notInArchivePredicate, undeletedPredicate, newsletterLabelPredicate - ]) - case .recommended: - // non-archived or deleted items with the Newsletter label - let recommendedPredicate = NSPredicate( - format: "recommendations.@count > 0" - ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - notInArchivePredicate, undeletedPredicate, recommendedPredicate - ]) - } - } - - var sortDescriptor: NSSortDescriptor { - let savedAtSort = NSSortDescriptor(key: #keyPath(LinkedItem.savedAt), ascending: false) - switch self { - case .continueReading: - return NSSortDescriptor(key: #keyPath(LinkedItem.readAt), ascending: false) - case .pinned: - return NSSortDescriptor(key: #keyPath(LinkedItem.updatedAt), ascending: false) - default: - return savedAtSort - } - } -} +// public enum FeaturedItemFilter: String, CaseIterable { +// case continueReading +// case recommended +// case newsletters +// case pinned +// } +// +// public extension FeaturedItemFilter { +// var title: String { +// switch self { +// case .continueReading: +// return "Continue Reading" +// case .recommended: +// return "Recommended" +// case .newsletters: +// return "Newsletters" +// case .pinned: +// return "Pinned" +// } +// } +// +// var emptyMessage: String { +// switch self { +// case .continueReading: +// return "Your recently read items will appear here." +// case .pinned: +// return "Create a label named Pinned and add it to items you would like to appear here." +// case .recommended: +// return "Reads recommended in your Clubs will appear here." +// case .newsletters: +// return "All your Newsletters will appear here." +// } +// } +// +// var predicate: NSPredicate { +// let undeletedPredicate = NSPredicate( +// format: "%K != %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue) +// ) +// let notInArchivePredicate = NSPredicate( +// format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber +// ) +// +// switch self { +// case .continueReading: +// // Use > 1 instead of 0 so its only reads they have made slight progress on. +// let continueReadingPredicate = NSPredicate( +// format: "readingProgress > 1 AND readingProgress < 100 AND readAt != nil" +// ) +// return NSCompoundPredicate(andPredicateWithSubpredicates: [ +// continueReadingPredicate, undeletedPredicate, notInArchivePredicate +// ]) +// case .pinned: +// let pinnedPredicate = NSPredicate( +// format: "SUBQUERY(labels, $label, $label.name == \"Pinned\").@count > 0" +// ) +// return NSCompoundPredicate(andPredicateWithSubpredicates: [ +// notInArchivePredicate, undeletedPredicate, pinnedPredicate +// ]) +// case .newsletters: +// // non-archived or deleted items with the Newsletter label +// let newsletterLabelPredicate = NSPredicate( +// format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0" +// ) +// return NSCompoundPredicate(andPredicateWithSubpredicates: [ +// notInArchivePredicate, undeletedPredicate, newsletterLabelPredicate +// ]) +// case .recommended: +// // non-archived or deleted items with the Newsletter label +// let recommendedPredicate = NSPredicate( +// format: "recommendations.@count > 0" +// ) +// return NSCompoundPredicate(andPredicateWithSubpredicates: [ +// notInArchivePredicate, undeletedPredicate, recommendedPredicate +// ]) +// } +// } +// +// var sortDescriptor: NSSortDescriptor { +// let savedAtSort = NSSortDescriptor(key: #keyPath(LinkedItem.savedAt), ascending: false) +// switch self { +// case .continueReading: +// return NSSortDescriptor(key: #keyPath(LinkedItem.readAt), ascending: false) +// case .pinned: +// return NSSortDescriptor(key: #keyPath(LinkedItem.updatedAt), ascending: false) +// default: +// return savedAtSort +// } +// } +// } diff --git a/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift b/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift new file mode 100644 index 000000000..0ba7c82dc --- /dev/null +++ b/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift @@ -0,0 +1,227 @@ +import Foundation + +public enum LinkedItemFilter: String, CaseIterable { + case inbox + case feeds + case readlater + case newsletters + case downloaded + case recommended + case all + case archived + case deleted + case hasHighlights + case files +} + +public extension LinkedItemFilter { + var queryString: String { + switch self { + case .inbox: + return "in:inbox" + case .feeds: + return "label:RSS" + case .readlater: + return "in:library" + case .downloaded: + return "" + case .newsletters: + return "in:inbox label:Newsletter" + case .recommended: + return "recommendedBy:*" + case .all: + return "in:all" + case .archived: + return "in:archive" + case .deleted: + return "in:trash" + case .hasHighlights: + return "has:highlights" + case .files: + return "type:file" + } + } + + var allowLocalFetch: Bool { + switch self { + case .inbox: + return true + default: + return false + } + } + + var predicate: NSPredicate { + let undeletedPredicate = NSPredicate( + format: "%K != %i AND %K != \"DELETED\"", + #keyPath(LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue), + #keyPath(LibraryItem.state) + ) + let notInArchivePredicate = NSPredicate( + format: "%K == %@", #keyPath(LibraryItem.isArchived), Int(truncating: false) as NSNumber + ) + + switch self { + case .inbox: + // non-archived items + return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate]) + case .readlater: + // non-archived or deleted items without the Newsletter label + let nonNewsletterLabelPredicate = NSPredicate( + format: "NOT SUBQUERY(labels, $label, $label.name == \"Newsletter\") .@count > 0" + ) + let nonRSSPredicate = NSPredicate( + format: "NOT SUBQUERY(labels, $label, $label.name == \"RSS\") .@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate, nonRSSPredicate + ]) + case .downloaded: + // include pdf only + let hasHTMLContent = NSPredicate( + format: "htmlContent.length > 0" + ) + let isPDFPredicate = NSPredicate( + format: "%K == %@", #keyPath(LibraryItem.contentReader), "PDF" + ) + let localPDFURL = NSPredicate( + format: "localPDF.length > 0" + ) + let downloadedPDF = NSCompoundPredicate(andPredicateWithSubpredicates: [isPDFPredicate, localPDFURL]) + return NSCompoundPredicate(orPredicateWithSubpredicates: [hasHTMLContent, downloadedPDF]) + case .newsletters: + // non-archived or deleted items with the Newsletter label + let newsletterLabelPredicate = NSPredicate( + format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, newsletterLabelPredicate]) + case .feeds: + let feedLabelPredicate = NSPredicate( + format: "SUBQUERY(labels, $label, $label.name == \"RSS\").@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, feedLabelPredicate]) + case .recommended: + // non-archived or deleted items with the Newsletter label + let recommendedPredicate = NSPredicate( + format: "recommendations.@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, recommendedPredicate]) + case .all: + // include everything undeleted + return undeletedPredicate + case .archived: + let inArchivePredicate = NSPredicate( + format: "%K == %@", #keyPath(LibraryItem.isArchived), Int(truncating: true) as NSNumber + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, inArchivePredicate]) + case .deleted: + let deletedPredicate = NSPredicate( + format: "%K == %i", #keyPath(LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue) + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [deletedPredicate]) + case .files: + // include pdf only + let isPDFPredicate = NSPredicate( + format: "%K == %@", #keyPath(LibraryItem.contentReader), "PDF" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate]) + case .hasHighlights: + let hasHighlightsPredicate = NSPredicate( + format: "highlights.@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + hasHighlightsPredicate + ]) + } + } +} + +public enum FeaturedItemFilter: String, CaseIterable { + case continueReading + case recommended + case newsletters + case pinned +} + +public extension FeaturedItemFilter { + var title: String { + switch self { + case .continueReading: + return "Continue Reading" + case .recommended: + return "Recommended" + case .newsletters: + return "Newsletters" + case .pinned: + return "Pinned" + } + } + + var emptyMessage: String { + switch self { + case .continueReading: + return "Your recently read items will appear here." + case .pinned: + return "Create a label named Pinned and add it to items you would like to appear here." + case .recommended: + return "Reads recommended in your Clubs will appear here." + case .newsletters: + return "All your Newsletters will appear here." + } + } + + var predicate: NSPredicate { + let undeletedPredicate = NSPredicate( + format: "%K != %i", #keyPath(LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue) + ) + let notInArchivePredicate = NSPredicate( + format: "%K == %@", #keyPath(LibraryItem.isArchived), Int(truncating: false) as NSNumber + ) + + switch self { + case .continueReading: + // Use > 1 instead of 0 so its only reads they have made slight progress on. + let continueReadingPredicate = NSPredicate( + format: "readingProgress > 1 AND readingProgress < 100 AND readAt != nil" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + continueReadingPredicate, undeletedPredicate, notInArchivePredicate + ]) + case .pinned: + let pinnedPredicate = NSPredicate( + format: "SUBQUERY(labels, $label, $label.name == \"Pinned\").@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + notInArchivePredicate, undeletedPredicate, pinnedPredicate + ]) + case .newsletters: + // non-archived or deleted items with the Newsletter label + let newsletterLabelPredicate = NSPredicate( + format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + notInArchivePredicate, undeletedPredicate, newsletterLabelPredicate + ]) + case .recommended: + // non-archived or deleted items with the Newsletter label + let recommendedPredicate = NSPredicate( + format: "recommendations.@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + notInArchivePredicate, undeletedPredicate, recommendedPredicate + ]) + } + } + + var sortDescriptor: NSSortDescriptor { + let savedAtSort = NSSortDescriptor(key: #keyPath(LibraryItem.savedAt), ascending: false) + switch self { + case .continueReading: + return NSSortDescriptor(key: #keyPath(LibraryItem.readAt), ascending: false) + case .pinned: + return NSSortDescriptor(key: #keyPath(LibraryItem.updatedAt), ascending: false) + default: + return savedAtSort + } + } +} diff --git a/apple/OmnivoreKit/Sources/Models/LinkedItemSort.swift b/apple/OmnivoreKit/Sources/Models/LinkedItemSort.swift index 706e6833e..4759b3a2d 100644 --- a/apple/OmnivoreKit/Sources/Models/LinkedItemSort.swift +++ b/apple/OmnivoreKit/Sources/Models/LinkedItemSort.swift @@ -24,16 +24,16 @@ public extension LinkedItemSort { var sortDescriptors: [NSSortDescriptor] { switch self { case .newest: - return [NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: false)] + return [NSSortDescriptor(keyPath: \LibraryItem.savedAt, ascending: false)] case .oldest: - return [NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: true)] + return [NSSortDescriptor(keyPath: \LibraryItem.savedAt, ascending: true)] case .recentlyRead: return [ - NSSortDescriptor(keyPath: \LinkedItem.readAt, ascending: false), - NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: false) + NSSortDescriptor(keyPath: \LibraryItem.readAt, ascending: false), + NSSortDescriptor(keyPath: \LibraryItem.savedAt, ascending: false) ] case .recentlyPublished: - return [NSSortDescriptor(keyPath: \LinkedItem.publishDate, ascending: false)] + return [NSSortDescriptor(keyPath: \LibraryItem.publishDate, ascending: false)] } } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/ContentLoading.swift b/apple/OmnivoreKit/Sources/Services/DataService/ContentLoading.swift index a5427549b..2affe061d 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/ContentLoading.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/ContentLoading.swift @@ -69,7 +69,7 @@ extension DataService { } func cachedArticleContent(itemID: String) async -> ArticleContent? { - let linkedItemFetchRequest: NSFetchRequest = LinkedItem.fetchRequest() + let linkedItemFetchRequest: NSFetchRequest = LibraryItem.fetchRequest() linkedItemFetchRequest.predicate = NSPredicate( format: "id == %@", itemID ) @@ -105,11 +105,11 @@ extension DataService { await backgroundContext.perform { [weak self] in guard let self = self else { return } - let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() fetchRequest.predicate = NSPredicate(format: "id == %@", articleProps.item.id) let existingItem = try? self.backgroundContext.fetch(fetchRequest).first - let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: self.backgroundContext) + let linkedItem = existingItem ?? LibraryItem(entity: LibraryItem.entity(), insertInto: self.backgroundContext) objectID = linkedItem.objectID let highlightObjects = articleProps.highlights.map { @@ -190,14 +190,14 @@ extension DataService { /// - Returns: The id of the CoreData object if found. func linkedItemID(from requestID: String) async -> String? { await backgroundContext.perform(schedule: .immediate) { - let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() fetchRequest.predicate = NSPredicate(format: "createdId == %@ OR id == %@", requestID, requestID) return try? self.backgroundContext.fetch(fetchRequest).first?.unwrappedID } } func syncUnsyncedArticleContent(itemID: String) async { - let linkedItemFetchRequest: NSFetchRequest = LinkedItem.fetchRequest() + let linkedItemFetchRequest: NSFetchRequest = LibraryItem.fetchRequest() linkedItemFetchRequest.predicate = NSPredicate( format: "id == %@", itemID ) diff --git a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift index 880a88a44..dc8e30de7 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift @@ -69,7 +69,7 @@ public final class DataService: ObservableObject { } public func cleanupDeletedItems(in context: NSManagedObjectContext) { - let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() let calendar = Calendar.current let oneDayAgo = calendar.date(byAdding: .day, value: -1, to: Date())! @@ -219,12 +219,12 @@ public final class DataService: ObservableObject { try await backgroundContext.perform { [weak self] in guard let self = self else { return } - let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() fetchRequest.predicate = NSPredicate(format: "pageURLString = %@", normalizedURL) let currentTime = Date() let existingItem = try? self.backgroundContext.fetch(fetchRequest).first - let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: self.backgroundContext) + let linkedItem = existingItem ?? LibraryItem(entity: LibraryItem.entity(), insertInto: self.backgroundContext) linkedItem.createdId = requestId linkedItem.id = existingItem?.unwrappedID ?? requestId diff --git a/apple/OmnivoreKit/Sources/Services/DataService/FetchLinkedItemsBackgroundTask.swift b/apple/OmnivoreKit/Sources/Services/DataService/FetchLinkedItemsBackgroundTask.swift index af354f3e4..bf14485de 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/FetchLinkedItemsBackgroundTask.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/FetchLinkedItemsBackgroundTask.swift @@ -48,7 +48,7 @@ extension DataService { } func itemsNotInStore(from itemIDs: [String]) async -> [String] { - let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() fetchRequest.predicate = NSPredicate(format: "id IN %@", itemIDs) return await backgroundContext.perform(schedule: .immediate) { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index e5df26be6..f3cf55737 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -667,6 +667,7 @@ extension Objects { let contentReader: [String: Enums.ContentReader] let createdAt: [String: DateTime] let description: [String: String] + let folder: [String: String] let hasContent: [String: Bool] let hash: [String: String] let highlights: [String: [Objects.Highlight]] @@ -741,6 +742,10 @@ extension Objects.Article: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "folder": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "hasContent": if let value = try container.decode(Bool?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -896,6 +901,7 @@ extension Objects.Article: Decodable { contentReader = map["contentReader"] createdAt = map["createdAt"] description = map["description"] + folder = map["folder"] hasContent = map["hasContent"] hash = map["hash"] highlights = map["highlights"] @@ -1019,6 +1025,24 @@ extension Fields where TypeLock == Objects.Article { } } + func folder() throws -> String { + let field = GraphQLField.leaf( + name: "folder", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.folder[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + func hasContent() throws -> Bool? { let field = GraphQLField.leaf( name: "hasContent", @@ -5609,6 +5633,251 @@ extension Selection where TypeLock == Never, Type == Never { typealias Feature = Selection } +extension Objects { + struct Feed { + let __typename: TypeName = .feed + let author: [String: String] + let createdAt: [String: DateTime] + let description: [String: String] + let id: [String: String] + let image: [String: String] + let publishedAt: [String: DateTime] + let title: [String: String] + let updatedAt: [String: DateTime] + let url: [String: String] + + enum TypeName: String, Codable { + case feed = "Feed" + } + } +} + +extension Objects.Feed: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "author": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "createdAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "description": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "image": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "publishedAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "title": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "updatedAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "url": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + author = map["author"] + createdAt = map["createdAt"] + description = map["description"] + id = map["id"] + image = map["image"] + publishedAt = map["publishedAt"] + title = map["title"] + updatedAt = map["updatedAt"] + url = map["url"] + } +} + +extension Fields where TypeLock == Objects.Feed { + func author() throws -> String? { + let field = GraphQLField.leaf( + name: "author", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.author[field.alias!] + case .mocking: + return nil + } + } + + func createdAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "createdAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.createdAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } + + func description() throws -> String? { + let field = GraphQLField.leaf( + name: "description", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.description[field.alias!] + case .mocking: + return nil + } + } + + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func image() throws -> String? { + let field = GraphQLField.leaf( + name: "image", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.image[field.alias!] + case .mocking: + return nil + } + } + + func publishedAt() throws -> DateTime? { + let field = GraphQLField.leaf( + name: "publishedAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.publishedAt[field.alias!] + case .mocking: + return nil + } + } + + func title() throws -> String { + let field = GraphQLField.leaf( + name: "title", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.title[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func updatedAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "updatedAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.updatedAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } + + func url() throws -> String { + let field = GraphQLField.leaf( + name: "url", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.url[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias Feed = Selection +} + extension Objects { struct FeedArticle { let __typename: TypeName = .feedArticle @@ -6125,6 +6394,252 @@ extension Selection where TypeLock == Never, Type == Never { typealias FeedArticlesSuccess = Selection } +extension Objects { + struct FeedEdge { + let __typename: TypeName = .feedEdge + let cursor: [String: String] + let node: [String: Objects.Feed] + + enum TypeName: String, Codable { + case feedEdge = "FeedEdge" + } + } +} + +extension Objects.FeedEdge: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "cursor": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "node": + if let value = try container.decode(Objects.Feed?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + cursor = map["cursor"] + node = map["node"] + } +} + +extension Fields where TypeLock == Objects.FeedEdge { + func cursor() throws -> String { + let field = GraphQLField.leaf( + name: "cursor", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.cursor[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func node(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "node", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.node[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias FeedEdge = Selection +} + +extension Objects { + struct FeedsError { + let __typename: TypeName = .feedsError + let errorCodes: [String: [Enums.FeedsErrorCode]] + + enum TypeName: String, Codable { + case feedsError = "FeedsError" + } + } +} + +extension Objects.FeedsError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.FeedsErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.FeedsError { + func errorCodes() throws -> [Enums.FeedsErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias FeedsError = Selection +} + +extension Objects { + struct FeedsSuccess { + let __typename: TypeName = .feedsSuccess + let edges: [String: [Objects.FeedEdge]] + let pageInfo: [String: Objects.PageInfo] + + enum TypeName: String, Codable { + case feedsSuccess = "FeedsSuccess" + } + } +} + +extension Objects.FeedsSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "edges": + if let value = try container.decode([Objects.FeedEdge]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageInfo": + if let value = try container.decode(Objects.PageInfo?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + edges = map["edges"] + pageInfo = map["pageInfo"] + } +} + +extension Fields where TypeLock == Objects.FeedsSuccess { + func edges(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "edges", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.edges[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + + func pageInfo(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "pageInfo", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.pageInfo[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias FeedsSuccess = Selection +} + extension Objects { struct Filter { let __typename: TypeName = .filter @@ -10397,6 +10912,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias MoveLabelSuccess = Selection } +extension Objects { + struct MoveToFolderError { + let __typename: TypeName = .moveToFolderError + let errorCodes: [String: [Enums.MoveToFolderErrorCode]] + + enum TypeName: String, Codable { + case moveToFolderError = "MoveToFolderError" + } + } +} + +extension Objects.MoveToFolderError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.MoveToFolderErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.MoveToFolderError { + func errorCodes() throws -> [Enums.MoveToFolderErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias MoveToFolderError = Selection +} + +extension Objects { + struct MoveToFolderSuccess { + let __typename: TypeName = .moveToFolderSuccess + let articleSavingRequest: [String: Objects.ArticleSavingRequest] + + enum TypeName: String, Codable { + case moveToFolderSuccess = "MoveToFolderSuccess" + } + } +} + +extension Objects.MoveToFolderSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "articleSavingRequest": + if let value = try container.decode(Objects.ArticleSavingRequest?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + articleSavingRequest = map["articleSavingRequest"] + } +} + +extension Fields where TypeLock == Objects.MoveToFolderSuccess { + func articleSavingRequest(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "articleSavingRequest", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.articleSavingRequest[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias MoveToFolderSuccess = Selection +} + extension Objects { struct Mutation { let __typename: TypeName = .mutation @@ -10427,6 +11073,7 @@ extension Objects { let mergeHighlight: [String: Unions.MergeHighlightResult] let moveFilter: [String: Unions.MoveFilterResult] let moveLabel: [String: Unions.MoveLabelResult] + let moveToFolder: [String: Unions.MoveToFolderResult] let optInFeature: [String: Unions.OptInFeatureResult] let recommend: [String: Unions.RecommendResult] let recommendHighlights: [String: Unions.RecommendHighlightsResult] @@ -10586,6 +11233,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.MoveLabelResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "moveToFolder": + if let value = try container.decode(Unions.MoveToFolderResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "optInFeature": if let value = try container.decode(Unions.OptInFeatureResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -10751,6 +11402,7 @@ extension Objects.Mutation: Decodable { mergeHighlight = map["mergeHighlight"] moveFilter = map["moveFilter"] moveLabel = map["moveLabel"] + moveToFolder = map["moveToFolder"] optInFeature = map["optInFeature"] recommend = map["recommend"] recommendHighlights = map["recommendHighlights"] @@ -11300,6 +11952,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func moveToFolder(folder: String, id: String, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "moveToFolder", + arguments: [Argument(name: "folder", type: "String!", value: folder), Argument(name: "id", type: "ID!", value: id)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.moveToFolder[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func optInFeature(input: InputObjects.OptInFeatureInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "optInFeature", @@ -13012,6 +13683,7 @@ extension Objects { let article: [String: Unions.ArticleResult] let articleSavingRequest: [String: Unions.ArticleSavingRequestResult] let deviceTokens: [String: Unions.DeviceTokensResult] + let feeds: [String: Unions.FeedsResult] let filters: [String: Unions.FiltersResult] let getUserPersonalization: [String: Unions.GetUserPersonalizationResult] let groups: [String: Unions.GroupsResult] @@ -13068,6 +13740,10 @@ extension Objects.Query: Decodable { if let value = try container.decode(Unions.DeviceTokensResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "feeds": + if let value = try container.decode(Unions.FeedsResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "filters": if let value = try container.decode(Unions.FiltersResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -13166,6 +13842,7 @@ extension Objects.Query: Decodable { article = map["article"] articleSavingRequest = map["articleSavingRequest"] deviceTokens = map["deviceTokens"] + feeds = map["feeds"] filters = map["filters"] getUserPersonalization = map["getUserPersonalization"] groups = map["groups"] @@ -13267,6 +13944,25 @@ extension Fields where TypeLock == Objects.Query { } } + func feeds(input: InputObjects.FeedsInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "feeds", + arguments: [Argument(name: "input", type: "FeedsInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.feeds[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func filters(selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "filters", @@ -16834,16 +17530,20 @@ extension Objects { let contentReader: [String: Enums.ContentReader] let createdAt: [String: DateTime] let description: [String: String] + let folder: [String: String] let highlights: [String: [Objects.Highlight]] let id: [String: String] let image: [String: String] let isArchived: [String: Bool] let labels: [String: [Objects.Label]] let language: [String: String] + let links: [String: String] let originalArticleUrl: [String: String] let ownedByViewer: [String: Bool] let pageId: [String: String] let pageType: [String: Enums.PageType] + let previewContent: [String: String] + let previewContentType: [String: String] let publishedAt: [String: DateTime] let quote: [String: String] let readAt: [String: DateTime] @@ -16916,6 +17616,10 @@ extension Objects.SearchItem: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "folder": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "highlights": if let value = try container.decode([Objects.Highlight]?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -16940,6 +17644,10 @@ extension Objects.SearchItem: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "links": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "originalArticleUrl": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -16956,6 +17664,14 @@ extension Objects.SearchItem: Decodable { if let value = try container.decode(Enums.PageType?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "previewContent": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "previewContentType": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "publishedAt": if let value = try container.decode(DateTime?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -17058,16 +17774,20 @@ extension Objects.SearchItem: Decodable { contentReader = map["contentReader"] createdAt = map["createdAt"] description = map["description"] + folder = map["folder"] highlights = map["highlights"] id = map["id"] image = map["image"] isArchived = map["isArchived"] labels = map["labels"] language = map["language"] + links = map["links"] originalArticleUrl = map["originalArticleUrl"] ownedByViewer = map["ownedByViewer"] pageId = map["pageId"] pageType = map["pageType"] + previewContent = map["previewContent"] + previewContentType = map["previewContentType"] publishedAt = map["publishedAt"] quote = map["quote"] readAt = map["readAt"] @@ -17219,6 +17939,24 @@ extension Fields where TypeLock == Objects.SearchItem { } } + func folder() throws -> String { + let field = GraphQLField.leaf( + name: "folder", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.folder[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + func highlights(selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "highlights", @@ -17317,6 +18055,21 @@ extension Fields where TypeLock == Objects.SearchItem { } } + func links() throws -> String? { + let field = GraphQLField.leaf( + name: "links", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.links[field.alias!] + case .mocking: + return nil + } + } + func originalArticleUrl() throws -> String? { let field = GraphQLField.leaf( name: "originalArticleUrl", @@ -17380,6 +18133,36 @@ extension Fields where TypeLock == Objects.SearchItem { } } + func previewContent() throws -> String? { + let field = GraphQLField.leaf( + name: "previewContent", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.previewContent[field.alias!] + case .mocking: + return nil + } + } + + func previewContentType() throws -> String? { + let field = GraphQLField.leaf( + name: "previewContentType", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.previewContentType[field.alias!] + case .mocking: + return nil + } + } + func publishedAt() throws -> DateTime? { let field = GraphQLField.leaf( name: "publishedAt", @@ -19891,11 +20674,13 @@ extension Selection where TypeLock == Never, Type == Never { extension Objects { struct Subscription { let __typename: TypeName = .subscription + let autoAddToLibrary: [String: Bool] let count: [String: Int] let createdAt: [String: DateTime] let description: [String: String] let icon: [String: String] let id: [String: String] + let isPrivate: [String: Bool] let lastFetchedAt: [String: DateTime] let name: [String: String] let newsletterEmail: [String: String] @@ -19924,6 +20709,10 @@ extension Objects.Subscription: Decodable { let field = GraphQLField.getFieldNameFromAlias(alias) switch field { + case "autoAddToLibrary": + if let value = try container.decode(Bool?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "count": if let value = try container.decode(Int?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -19944,6 +20733,10 @@ extension Objects.Subscription: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "isPrivate": + if let value = try container.decode(Bool?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "lastFetchedAt": if let value = try container.decode(DateTime?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -19990,11 +20783,13 @@ extension Objects.Subscription: Decodable { } } + autoAddToLibrary = map["autoAddToLibrary"] count = map["count"] createdAt = map["createdAt"] description = map["description"] icon = map["icon"] id = map["id"] + isPrivate = map["isPrivate"] lastFetchedAt = map["lastFetchedAt"] name = map["name"] newsletterEmail = map["newsletterEmail"] @@ -20008,6 +20803,21 @@ extension Objects.Subscription: Decodable { } extension Fields where TypeLock == Objects.Subscription { + func autoAddToLibrary() throws -> Bool? { + let field = GraphQLField.leaf( + name: "autoAddToLibrary", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.autoAddToLibrary[field.alias!] + case .mocking: + return nil + } + } + func count() throws -> Int { let field = GraphQLField.leaf( name: "count", @@ -20092,6 +20902,21 @@ extension Fields where TypeLock == Objects.Subscription { } } + func isPrivate() throws -> Bool? { + let field = GraphQLField.leaf( + name: "isPrivate", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.isPrivate[field.alias!] + case .mocking: + return nil + } + } + func lastFetchedAt() throws -> DateTime? { let field = GraphQLField.leaf( name: "lastFetchedAt", @@ -23477,6 +24302,7 @@ extension Selection where TypeLock == Never, Type == Never { extension Objects { struct UserPersonalization { let __typename: TypeName = .userPersonalization + let fields: [String: String] let fontFamily: [String: String] let fontSize: [String: Int] let id: [String: String] @@ -23507,6 +24333,10 @@ extension Objects.UserPersonalization: Decodable { let field = GraphQLField.getFieldNameFromAlias(alias) switch field { + case "fields": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "fontFamily": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -23561,6 +24391,7 @@ extension Objects.UserPersonalization: Decodable { } } + fields = map["fields"] fontFamily = map["fontFamily"] fontSize = map["fontSize"] id = map["id"] @@ -23576,6 +24407,21 @@ extension Objects.UserPersonalization: Decodable { } extension Fields where TypeLock == Objects.UserPersonalization { + func fields() throws -> String? { + let field = GraphQLField.leaf( + name: "fields", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.fields[field.alias!] + case .mocking: + return nil + } + } + func fontFamily() throws -> String? { let field = GraphQLField.leaf( name: "fontFamily", @@ -26618,6 +27464,86 @@ extension Selection where TypeLock == Never, Type == Never { typealias FeedArticlesResult = Selection } +extension Unions { + struct FeedsResult { + let __typename: TypeName + let edges: [String: [Objects.FeedEdge]] + let errorCodes: [String: [Enums.FeedsErrorCode]] + let pageInfo: [String: Objects.PageInfo] + + enum TypeName: String, Codable { + case feedsError = "FeedsError" + case feedsSuccess = "FeedsSuccess" + } + } +} + +extension Unions.FeedsResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "edges": + if let value = try container.decode([Objects.FeedEdge]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "errorCodes": + if let value = try container.decode([Enums.FeedsErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageInfo": + if let value = try container.decode(Objects.PageInfo?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + edges = map["edges"] + errorCodes = map["errorCodes"] + pageInfo = map["pageInfo"] + } +} + +extension Fields where TypeLock == Unions.FeedsResult { + func on(feedsError: Selection, feedsSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "FeedsError", selection: feedsError.selection), GraphQLField.fragment(type: "FeedsSuccess", selection: feedsSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .feedsError: + let data = Objects.FeedsError(errorCodes: data.errorCodes) + return try feedsError.decode(data: data) + case .feedsSuccess: + let data = Objects.FeedsSuccess(edges: data.edges, pageInfo: data.pageInfo) + return try feedsSuccess.decode(data: data) + } + case .mocking: + return feedsError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias FeedsResult = Selection +} + extension Unions { struct FiltersResult { let __typename: TypeName @@ -27956,6 +28882,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias MoveLabelResult = Selection } +extension Unions { + struct MoveToFolderResult { + let __typename: TypeName + let articleSavingRequest: [String: Objects.ArticleSavingRequest] + let errorCodes: [String: [Enums.MoveToFolderErrorCode]] + + enum TypeName: String, Codable { + case moveToFolderError = "MoveToFolderError" + case moveToFolderSuccess = "MoveToFolderSuccess" + } + } +} + +extension Unions.MoveToFolderResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "articleSavingRequest": + if let value = try container.decode(Objects.ArticleSavingRequest?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "errorCodes": + if let value = try container.decode([Enums.MoveToFolderErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + articleSavingRequest = map["articleSavingRequest"] + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Unions.MoveToFolderResult { + func on(moveToFolderError: Selection, moveToFolderSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "MoveToFolderError", selection: moveToFolderError.selection), GraphQLField.fragment(type: "MoveToFolderSuccess", selection: moveToFolderSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .moveToFolderError: + let data = Objects.MoveToFolderError(errorCodes: data.errorCodes) + return try moveToFolderError.decode(data: data) + case .moveToFolderSuccess: + let data = Objects.MoveToFolderSuccess(articleSavingRequest: data.articleSavingRequest) + return try moveToFolderSuccess.decode(data: data) + } + case .mocking: + return moveToFolderError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias MoveToFolderResult = Selection +} + extension Unions { struct NewsletterEmailsResult { let __typename: TypeName @@ -32009,6 +33009,15 @@ extension Enums { } } +extension Enums { + /// FeedsErrorCode + enum FeedsErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// FiltersErrorCode enum FiltersErrorCode: String, CaseIterable, Codable { @@ -32079,6 +33088,19 @@ extension Enums { } } +extension Enums { + /// ImportItemState + enum ImportItemState: String, CaseIterable, Codable { + case all = "ALL" + + case archived = "ARCHIVED" + + case unarchived = "UNARCHIVED" + + case unread = "UNREAD" + } +} + extension Enums { /// IntegrationType enum IntegrationType: String, CaseIterable, Codable { @@ -32202,6 +33224,17 @@ extension Enums { } } +extension Enums { + /// MoveToFolderErrorCode + enum MoveToFolderErrorCode: String, CaseIterable, Codable { + case alreadyExists = "ALREADY_EXISTS" + + case badRequest = "BAD_REQUEST" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// NewsletterEmailsErrorCode enum NewsletterEmailsErrorCode: String, CaseIterable, Codable { @@ -33220,6 +34253,33 @@ extension InputObjects { } } +extension InputObjects { + struct FeedsInput: Encodable, Hashable { + var after: OptionalArgument = .absent() + + var first: OptionalArgument = .absent() + + var query: OptionalArgument = .absent() + + var sort: OptionalArgument = .absent() + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if after.hasValue { try container.encode(after, forKey: .after) } + if first.hasValue { try container.encode(first, forKey: .first) } + if query.hasValue { try container.encode(query, forKey: .query) } + if sort.hasValue { try container.encode(sort, forKey: .sort) } + } + + enum CodingKeys: String, CodingKey { + case after + case first + case query + case sort + } + } +} + extension InputObjects { struct GenerateApiKeyInput: Encodable, Hashable { var expiresAt: DateTime @@ -33900,8 +34960,12 @@ extension InputObjects { var id: OptionalArgument = .absent() + var importItemState: OptionalArgument = .absent() + var name: String + var syncedAt: OptionalArgument = .absent() + var token: String var type: OptionalArgument = .absent() @@ -33910,7 +34974,9 @@ extension InputObjects { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(enabled, forKey: .enabled) if id.hasValue { try container.encode(id, forKey: .id) } + if importItemState.hasValue { try container.encode(importItemState, forKey: .importItemState) } try container.encode(name, forKey: .name) + if syncedAt.hasValue { try container.encode(syncedAt, forKey: .syncedAt) } try container.encode(token, forKey: .token) if type.hasValue { try container.encode(type, forKey: .type) } } @@ -33918,7 +34984,9 @@ extension InputObjects { enum CodingKeys: String, CodingKey { case enabled case id + case importItemState case name + case syncedAt case token case type } @@ -34058,6 +35126,8 @@ extension InputObjects { extension InputObjects { struct SetUserPersonalizationInput: Encodable, Hashable { + var fields: OptionalArgument = .absent() + var fontFamily: OptionalArgument = .absent() var fontSize: OptionalArgument = .absent() @@ -34080,6 +35150,7 @@ extension InputObjects { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + if fields.hasValue { try container.encode(fields, forKey: .fields) } if fontFamily.hasValue { try container.encode(fontFamily, forKey: .fontFamily) } if fontSize.hasValue { try container.encode(fontSize, forKey: .fontSize) } if libraryLayoutType.hasValue { try container.encode(libraryLayoutType, forKey: .libraryLayoutType) } @@ -34093,6 +35164,7 @@ extension InputObjects { } enum CodingKeys: String, CodingKey { + case fields case fontFamily case fontSize case libraryLayoutType @@ -34163,17 +35235,25 @@ extension InputObjects { extension InputObjects { struct SubscribeInput: Encodable, Hashable { + var autoAddToLibrary: OptionalArgument = .absent() + + var isPrivate: OptionalArgument = .absent() + var subscriptionType: OptionalArgument = .absent() var url: String func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + if autoAddToLibrary.hasValue { try container.encode(autoAddToLibrary, forKey: .autoAddToLibrary) } + if isPrivate.hasValue { try container.encode(isPrivate, forKey: .isPrivate) } if subscriptionType.hasValue { try container.encode(subscriptionType, forKey: .subscriptionType) } try container.encode(url, forKey: .url) } enum CodingKeys: String, CodingKey { + case autoAddToLibrary + case isPrivate case subscriptionType case url } @@ -34429,10 +35509,14 @@ extension InputObjects { extension InputObjects { struct UpdateSubscriptionInput: Encodable, Hashable { + var autoAddToLibrary: OptionalArgument = .absent() + var description: OptionalArgument = .absent() var id: String + var isPrivate: OptionalArgument = .absent() + var lastFetchedAt: OptionalArgument = .absent() var lastFetchedChecksum: OptionalArgument = .absent() @@ -34445,8 +35529,10 @@ extension InputObjects { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + if autoAddToLibrary.hasValue { try container.encode(autoAddToLibrary, forKey: .autoAddToLibrary) } if description.hasValue { try container.encode(description, forKey: .description) } try container.encode(id, forKey: .id) + if isPrivate.hasValue { try container.encode(isPrivate, forKey: .isPrivate) } if lastFetchedAt.hasValue { try container.encode(lastFetchedAt, forKey: .lastFetchedAt) } if lastFetchedChecksum.hasValue { try container.encode(lastFetchedChecksum, forKey: .lastFetchedChecksum) } if name.hasValue { try container.encode(name, forKey: .name) } @@ -34455,8 +35541,10 @@ extension InputObjects { } enum CodingKeys: String, CodingKey { + case autoAddToLibrary case description case id + case isPrivate case lastFetchedAt case lastFetchedChecksum case name diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/ArchiveLink.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/ArchiveLink.swift index 881edfd26..a2355cff8 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/ArchiveLink.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/ArchiveLink.swift @@ -9,7 +9,7 @@ extension DataService { // Update CoreData backgroundContext.perform { [weak self] in guard let self = self else { return } - guard let linkedItem = self.backgroundContext.object(with: objectID) as? LinkedItem else { return } + guard let linkedItem = self.backgroundContext.object(with: objectID) as? LibraryItem else { return } linkedItem.update(inContext: self.backgroundContext, newIsArchivedValue: archived) // Send update to server @@ -54,7 +54,7 @@ extension DataService { let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync context.perform { - guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return } + guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: context) else { return } linkedItem.serverSyncStatus = Int64(syncStatus.rawValue) do { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/BulkActionMutation.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/BulkActionMutation.swift index fcc12fcd4..b74fc4d68 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/BulkActionMutation.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/BulkActionMutation.swift @@ -29,7 +29,7 @@ public extension DataService { // If the item is still available locally, update its state backgroundContext.performAndWait { items.forEach { itemID in - if let linkedItem = LinkedItem.lookup(byID: itemID, inContext: backgroundContext) { + if let linkedItem = Models.LibraryItem.lookup(byID: itemID, inContext: backgroundContext) { if action == .delete { linkedItem.state = "DELETED" linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue) diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RemoveLink.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RemoveLink.swift index 36e7aa173..eeccc08da 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RemoveLink.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RemoveLink.swift @@ -10,13 +10,13 @@ public extension DataService { var linkedItemID: String? viewContext.performAndWait { - guard let linkedItem = self.viewContext.object(with: objectID) as? LinkedItem else { return } + guard let linkedItem = self.viewContext.object(with: objectID) as? LibraryItem else { return } linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue) linkedItemID = linkedItem.id } viewContext.perform { - guard let linkedItem = self.viewContext.object(with: objectID) as? LinkedItem else { return } + guard let linkedItem = self.viewContext.object(with: objectID) as? LibraryItem else { return } linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue) linkedItemID = linkedItem.id @@ -78,7 +78,7 @@ public extension DataService { let isSyncSuccess = data != nil context.perform { - guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return } + guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: context) else { return } if isSyncSuccess { linkedItem.remove(inContext: context) diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UndeleteItem.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UndeleteItem.swift index b75c73933..750d7a046 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UndeleteItem.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UndeleteItem.swift @@ -8,7 +8,7 @@ public extension DataService { var itemUpdatedLocal = false // If the item is still available locally, update its state backgroundContext.performAndWait { - if let linkedItem = LinkedItem.lookup(byID: itemID, inContext: backgroundContext) { + if let linkedItem = LibraryItem.lookup(byID: itemID, inContext: backgroundContext) { linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsUpdate.rawValue) do { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift index 8536e9d97..7a16b19de 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift @@ -7,7 +7,7 @@ public extension DataService { func setItemLabels(itemID: String, labels: [InternalLinkedItemLabel]) { backgroundContext.perform { [weak self] in guard let self = self else { return } - guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return } + guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return } if let existingLabels = linkedItem.labels { linkedItem.removeFromLabels(existingLabels) @@ -65,7 +65,7 @@ public extension DataService { let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync context.perform { - guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return } + guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: context) else { return } linkedItem.serverSyncStatus = Int64(syncStatus.rawValue) do { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleListenProgressPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleListenProgressPublisher.swift index 51a62cd13..8958aad98 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleListenProgressPublisher.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleListenProgressPublisher.swift @@ -7,7 +7,7 @@ public extension DataService { func updateLinkListeningProgress(itemID: String, listenIndex: Int, listenOffset: Double, listenTime: Double) { backgroundContext.perform { [weak self] in guard let self = self else { return } - guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return } + guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return } linkedItem.update( inContext: self.backgroundContext, diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleReadingProgress.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleReadingProgress.swift index 100109009..c3d850348 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleReadingProgress.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleReadingProgress.swift @@ -7,7 +7,7 @@ extension DataService { public func updateLinkReadingProgress(itemID: String, readingProgress: Double, anchorIndex: Int, force: Bool?) { backgroundContext.perform { [weak self] in guard let self = self else { return } - guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return } + guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return } if let force = force, !force { if readingProgress != 0, readingProgress < linkedItem.readingProgress { @@ -70,7 +70,7 @@ extension DataService { let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync context.perform { - guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return } + guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: context) else { return } linkedItem.serverSyncStatus = Int64(syncStatus.rawValue) if let mutationResult = data?.data, case let MutationResult.saved(readAt) = mutationResult { linkedItem.readAt = readAt diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateLinkedItemTitle.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateLinkedItemTitle.swift index e4acbf144..ada7516cb 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateLinkedItemTitle.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateLinkedItemTitle.swift @@ -7,7 +7,7 @@ extension DataService { public func updateLinkedItemTitleAndDescription(itemID: String, title: String, description: String, author: String?) { backgroundContext.perform { [weak self] in guard let self = self else { return } - guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return } + guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return } linkedItem.update( inContext: self.backgroundContext, @@ -65,7 +65,7 @@ extension DataService { let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync context.perform { - guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return } + guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: context) else { return } linkedItem.serverSyncStatus = Int64(syncStatus.rawValue) do { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/OfflineSync.swift b/apple/OmnivoreKit/Sources/Services/DataService/OfflineSync.swift index 6f50cbd8d..f7417f262 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/OfflineSync.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/OfflineSync.swift @@ -5,11 +5,11 @@ import Utils public extension DataService { func syncOfflineItemsWithServerIfNeeded() async throws { - var unsyncedLinkedItems = [LinkedItem]() + var unsyncedLinkedItems = [LibraryItem]() var unsyncedHighlights = [Highlight]() // LinkedItems - let itemsFetchRequest: NSFetchRequest = LinkedItem.fetchRequest() + let itemsFetchRequest: NSFetchRequest = LibraryItem.fetchRequest() itemsFetchRequest.predicate = NSPredicate( format: "serverSyncStatus != %i", Int64(ServerSyncStatus.isNSync.rawValue) ) @@ -37,7 +37,7 @@ public extension DataService { private func updateLinkedItemStatus(id: String, newId: String?, status: ServerSyncStatus) async throws { backgroundContext.performAndWait { - let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() fetchRequest.predicate = NSPredicate(format: "id == %@", id) guard let linkedItem = (try? backgroundContext.fetch(fetchRequest))?.first else { return } @@ -107,7 +107,7 @@ public extension DataService { } } - func syncLocalCreatedLinkedItem(item: LinkedItem) { + func syncLocalCreatedLinkedItem(item: LibraryItem) { switch item.contentReader { case "PDF": let id = item.unwrappedID @@ -135,7 +135,7 @@ public extension DataService { } } - private func syncLinkedItems(unsyncedLinkedItems: [LinkedItem]) { + private func syncLinkedItems(unsyncedLinkedItems: [LibraryItem]) { for item in unsyncedLinkedItems { guard let syncStatus = ServerSyncStatus(rawValue: Int(item.serverSyncStatus)) else { continue } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemLoading.swift b/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemLoading.swift index a4d68bdce..d0e315318 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemLoading.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemLoading.swift @@ -13,7 +13,7 @@ public extension DataService { ) async throws -> LinkedItemSyncResult { let fetchResult = try await linkedItemUpdates(since: since, limit: 20, cursor: cursor, descending: descending) - LinkedItem.deleteItems(ids: fetchResult.deletedItemIDs, context: backgroundContext) + LibraryItem.deleteItems(ids: fetchResult.deletedItemIDs, context: backgroundContext) if fetchResult.items.persist(context: backgroundContext) == nil { throw BasicError.message(messageText: "CoreData error") diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Public/PDFLoading.swift b/apple/OmnivoreKit/Sources/Services/DataService/Public/PDFLoading.swift index 8b6520640..e892c3299 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Public/PDFLoading.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Public/PDFLoading.swift @@ -25,8 +25,8 @@ public extension DataService { .appendingPathComponent(UUID().uuidString + ".pdf") try await backgroundContext.perform { [weak self] in - let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() - fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LinkedItem.slug), slug) + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LibraryItem.slug), slug) let linkedItem = try? self?.backgroundContext.fetch(fetchRequest).first guard let linkedItem = linkedItem else { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift index f654003f3..9a7d38ed6 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift @@ -5,7 +5,7 @@ import SwiftGraphQL import Utils struct ArticleProps { - let item: InternalLinkedItem + let item: InternalLibraryItem let htmlContent: String let highlights: [InternalHighlight] } @@ -20,13 +20,14 @@ extension DataService { let articleContentSelection = Selection.Article { ArticleProps( - item: InternalLinkedItem( + item: InternalLibraryItem( id: try $0.id(), title: try $0.title(), createdAt: try $0.createdAt().value ?? Date(), savedAt: try $0.savedAt().value ?? Date(), readAt: try $0.readAt()?.value, updatedAt: try $0.updatedAt()?.value ?? Date(), + folder: try $0.folder(), state: try $0.state()?.rawValue.asArticleContentStatus ?? .succeeded, readingProgress: try $0.readingProgressPercent(), readingProgressAnchor: try $0.readingProgressAnchorIndex(), diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift index 9c1dd7a98..649c353dd 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift @@ -4,12 +4,12 @@ import Models import SwiftGraphQL struct InternalLinkedItemQueryResult { - let items: [InternalLinkedItem] + let items: [InternalLibraryItem] let cursor: String? } struct InternalLinkedItemUpdatesQueryResult { - let items: [InternalLinkedItem] + let items: [InternalLibraryItem] let deletedItemIDs: [String] let cursor: String? let hasMoreItems: Bool @@ -19,7 +19,7 @@ struct InternalLinkedItemUpdatesQueryResult { private struct SyncItemEdge { let itemID: String let isDeletedItem: Bool - let item: InternalLinkedItem? + let item: InternalLibraryItem? } extension DataService { @@ -90,7 +90,7 @@ extension DataService { switch payload.data { case let .success(result: result): - var items = [InternalLinkedItem]() + var items = [InternalLibraryItem]() var deletedItemIDs = [String]() for edge in result.edges { @@ -186,13 +186,13 @@ extension DataService { /// - itemID: id of the item being requested /// - Returns: Returns an `InternalLinkedItem` or throws a `ContentFetchError` if /// request could not be completed - func fetchLinkedItem(username: String, itemID: String) async throws -> InternalLinkedItem { + func fetchLinkedItem(username: String, itemID: String) async throws -> InternalLibraryItem { struct ArticleProps { - let item: InternalLinkedItem + let item: InternalLibraryItem } enum QueryResult { - case success(result: InternalLinkedItem) + case success(result: InternalLibraryItem) case error(error: String) } @@ -252,13 +252,14 @@ let recommendationSelection = Selection.Recommendation { } private let libraryArticleSelection = Selection.Article { - InternalLinkedItem( + InternalLibraryItem( id: try $0.id(), title: try $0.title(), createdAt: try $0.createdAt().value ?? Date(), savedAt: try $0.savedAt().value ?? Date(), readAt: try $0.readAt()?.value, updatedAt: try $0.updatedAt()?.value ?? Date(), + folder: try $0.folder(), state: try $0.state()?.rawValue.asArticleContentStatus ?? .succeeded, readingProgress: try $0.readingProgressPercent(), readingProgressAnchor: try $0.readingProgressAnchorIndex(), @@ -292,13 +293,14 @@ private let syncItemEdgeSelection = Selection.SyncUpdatedItemEdge { } private let searchItemSelection = Selection.SearchItem { - InternalLinkedItem( + InternalLibraryItem( id: try $0.id(), title: try $0.title(), createdAt: try $0.createdAt().value ?? Date(), savedAt: try $0.savedAt().value ?? Date(), readAt: try $0.readAt()?.value, updatedAt: try $0.updatedAt()?.value ?? Date(), + folder: try $0.folder(), state: try $0.state()?.rawValue.asArticleContentStatus ?? .succeeded, readingProgress: try $0.readingProgressPercent(), readingProgressAnchor: try $0.readingProgressAnchorIndex(), diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalFilter.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalFilter.swift index f4de0afa4..ee51fd0fb 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalFilter.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalFilter.swift @@ -120,11 +120,11 @@ public struct InternalFilter: Encodable, Identifiable, Hashable { let undeletedPredicate = NSPredicate( format: "%K != %i AND %K != \"DELETED\"", - #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue), - #keyPath(LinkedItem.state) + #keyPath(Models.LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue), + #keyPath(Models.LibraryItem.state) ) let notInArchivePredicate = NSPredicate( - format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber + format: "%K == %@", #keyPath(Models.LibraryItem.isArchived), Int(truncating: false) as NSNumber ) switch name { @@ -148,7 +148,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable { format: "htmlContent.length > 0" ) let isPDFPredicate = NSPredicate( - format: "%K == %@", #keyPath(LinkedItem.contentReader), "PDF" + format: "%K == %@", #keyPath(Models.LibraryItem.contentReader), "PDF" ) let localPDFURL = NSPredicate( format: "localPDF.length > 0" @@ -177,18 +177,18 @@ public struct InternalFilter: Encodable, Identifiable, Hashable { return undeletedPredicate case "Archived": let inArchivePredicate = NSPredicate( - format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: true) as NSNumber + format: "%K == %@", #keyPath(Models.LibraryItem.isArchived), Int(truncating: true) as NSNumber ) return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, inArchivePredicate]) case "Deleted": let deletedPredicate = NSPredicate( - format: "%K == %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue) + format: "%K == %i", #keyPath(Models.LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue) ) return NSCompoundPredicate(andPredicateWithSubpredicates: [deletedPredicate]) case "Files": // include pdf only let isPDFPredicate = NSPredicate( - format: "%K == %@", #keyPath(LinkedItem.contentReader), "PDF" + format: "%K == %@", #keyPath(Models.LibraryItem.contentReader), "PDF" ) return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate]) case "Highlights": diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalHighlight.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalHighlight.swift index 03b829f46..ef0f0eb86 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalHighlight.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalHighlight.swift @@ -91,7 +91,7 @@ struct InternalHighlight: Encodable { let highlight = asManagedObject(context: context) if let associatedItemID = associatedItemID { - let linkedItem = LinkedItem.lookup(byID: associatedItemID, inContext: context) + let linkedItem = LibraryItem.lookup(byID: associatedItemID, inContext: context) linkedItem?.addToHighlights(highlight) } diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLibraryItem.swift similarity index 91% rename from apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift rename to apple/OmnivoreKit/Sources/Services/InternalModels/InternalLibraryItem.swift index 3031cd1ef..140ff1c53 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLibraryItem.swift @@ -2,13 +2,14 @@ import CoreData import Foundation import Models -struct InternalLinkedItem { +struct InternalLibraryItem { let id: String let title: String let createdAt: Date let savedAt: Date let readAt: Date? let updatedAt: Date + let folder: String let state: ArticleContentStatus var readingProgress: Double var readingProgressAnchor: Int @@ -38,9 +39,9 @@ struct InternalLinkedItem { return pageURLString.hasSuffix("pdf") } - func asManagedObject(inContext context: NSManagedObjectContext) -> LinkedItem { - let existingItem = LinkedItem.lookup(byID: id, inContext: context) - let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: context) + func asManagedObject(inContext context: NSManagedObjectContext) -> LibraryItem { + let existingItem = LibraryItem.lookup(byID: id, inContext: context) + let linkedItem = existingItem ?? LibraryItem(entity: LibraryItem.entity(), insertInto: context) linkedItem.id = id linkedItem.title = title @@ -48,6 +49,7 @@ struct InternalLinkedItem { linkedItem.savedAt = savedAt linkedItem.updatedAt = updatedAt linkedItem.readAt = readAt + linkedItem.folder = folder linkedItem.state = state.rawValue linkedItem.readingProgress = readingProgress linkedItem.readingProgressAnchor = Int64(readingProgressAnchor) @@ -89,9 +91,9 @@ struct InternalLinkedItem { } } -extension Sequence where Element == InternalLinkedItem { +extension Sequence where Element == InternalLibraryItem { func persist(context: NSManagedObjectContext) -> [NSManagedObjectID]? { - var linkedItems: [LinkedItem]? + var linkedItems: [LibraryItem]? context.performAndWait { linkedItems = map { $0.asManagedObject(inContext: context) } @@ -123,13 +125,14 @@ extension JSONArticle { func persistAsLinkedItem(context: NSManagedObjectContext) -> NSManagedObjectID? { var objectID: NSManagedObjectID? - let internalLinkedItem = InternalLinkedItem( + let internalLinkedItem = InternalLibraryItem( id: id, title: title, createdAt: createdAt, savedAt: savedAt, readAt: readAt, updatedAt: updatedAt, + folder: folder, state: .succeeded, readingProgress: readingProgressPercent, readingProgressAnchor: readingProgressAnchorIndex, diff --git a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift index 643b1d3cd..214444c79 100644 --- a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift +++ b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift @@ -54,6 +54,8 @@ public extension Color { static var noteContainer: Color { Color("_noteContainer", bundle: .module) } static var textFieldBackground: Color { Color("_textFieldBackground", bundle: .module) } + static var themeTabBarColor: Color { Color("_themeTabBarColor", bundle: .module) } + static var themeTabButtonColor: Color { Color("_themeTabButtonColor", bundle: .module) } // Apple system UIColor equivalents #if os(iOS) diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_themeTabBarColor.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_themeTabBarColor.colorset/Contents.json new file mode 100644 index 000000000..982111ca3 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_themeTabBarColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2A", + "green" : "0x2A", + "red" : "0x2A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_themeTabButtonColor.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_themeTabButtonColor.colorset/Contents.json new file mode 100644 index 000000000..2312e4873 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_themeTabButtonColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x93", + "green" : "0x8E", + "red" : "0x8E" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x93", + "green" : "0x8E", + "red" : "0x8E" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift index ebbdae6ef..5ee53b457 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift @@ -12,12 +12,12 @@ public enum GridCardAction { public struct GridCard: View { @Binding var isContextMenuOpen: Bool - let item: LinkedItem + let item: Models.LibraryItem let actionHandler: (GridCardAction) -> Void // let tapAction: () -> Void public init( - item: LinkedItem, + item: Models.LibraryItem, isContextMenuOpen: Binding, actionHandler: @escaping (GridCardAction) -> Void ) { diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryFeatureCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryFeatureCard.swift index 97ee23227..35913ceff 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryFeatureCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryFeatureCard.swift @@ -5,9 +5,9 @@ import Utils public struct LibraryFeatureCard: View { let viewer: Viewer? let tapHandler: () -> Void - @ObservedObject var item: LinkedItem + @ObservedObject var item: Models.LibraryItem - public init(item: LinkedItem, viewer: Viewer?, tapHandler: @escaping () -> Void = {}) { + public init(item: Models.LibraryItem, viewer: Viewer?, tapHandler: @escaping () -> Void = {}) { self.item = item self.viewer = viewer self.tapHandler = tapHandler diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift index 0b1e837cb..1797ac986 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift @@ -32,7 +32,7 @@ enum FlairLabels: String { } public extension View { - func draggableItem(item: LinkedItem) -> some View { + func draggableItem(item: Models.LibraryItem) -> some View { #if os(iOS) if #available(iOS 16.0, *), let url = item.deepLink { return AnyView(self.draggable(url) { @@ -46,10 +46,10 @@ public extension View { public struct LibraryItemCard: View { let viewer: Viewer? - @ObservedObject var item: LinkedItem + @ObservedObject var item: Models.LibraryItem @State var noteLineLimit: Int? = 3 - public init(item: LinkedItem, viewer: Viewer?) { + public init(item: Models.LibraryItem, viewer: Viewer?) { self.item = item self.viewer = viewer } diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.swift b/apple/OmnivoreKit/Sources/Views/Images/Images.swift index ce00165cb..1c771fd56 100644 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.swift +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.swift @@ -5,15 +5,11 @@ public extension Image { static var omnivoreTitleLogo: Image { Image("_omnivoreTitleLogo", bundle: .module) } static var googleIcon: Image { Image("_googleIcon", bundle: .module) } - static var homeTab: Image { Image("BookmarksSimple", bundle: .module) } - static var homeTabSelected: Image { Image("_homeTabSelected", bundle: .module) } - static var profileTab: Image { Image("_profileTab", bundle: .module) } - static var profileTabSelected: Image { Image("_profileTabSelected", bundle: .module) } static var dotsThree: Image { Image("_dots-three", bundle: .module) } - static var tabSubscriptions: Image { Image("_tab_subscriptions", bundle: .module).renderingMode(.template) } + static var tabFollowing: Image { Image("_tab_following", bundle: .module).renderingMode(.template) } static var tabLibrary: Image { Image("_tab_library", bundle: .module).renderingMode(.template) } - static var tabBriefing: Image { Image("_tab_briefing", bundle: .module).renderingMode(.template) } + static var tabSearch: Image { Image("_tab_search", bundle: .module).renderingMode(.template) } static var tabHighlights: Image { Image("_tab_highlights", bundle: .module).renderingMode(.template) } static var pinRotated: Image { Image("pin-rotated", bundle: .module) } diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_profileTab.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_profileTab.imageset/Contents.json deleted file mode 100644 index 3688423af..000000000 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_profileTab.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "profile-tab.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_profileTab.imageset/profile-tab.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_profileTab.imageset/profile-tab.svg deleted file mode 100644 index 046ff5225..000000000 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_profileTab.imageset/profile-tab.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_profileTabSelected.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_profileTabSelected.imageset/Contents.json deleted file mode 100644 index a4ca33e24..000000000 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_profileTabSelected.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "profile-tab-selected.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_profileTabSelected.imageset/profile-tab-selected.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_profileTabSelected.imageset/profile-tab-selected.svg deleted file mode 100644 index d224db571..000000000 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_profileTabSelected.imageset/profile-tab-selected.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_briefing.imageset/Group 1000002644.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_briefing.imageset/Group 1000002644.png deleted file mode 100644 index 3ea31a05b870e61d589bc7dd1ab39bb8994f66a0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1104 zcmV-W1h4yvP)ggAmZL6Z|SoS<-m> zu)Up$ZDNcQ8{3GVFg+vjyy5+ulu9mb@Oup}@@36yFT zA6FS;6~-KJ95w*pX0y592Z2x^B_s)2oP&xafpZ3oK?{E-Uh0EDB$DvS$#=C-u!nR$ znaJBmN1sgp8tU+nmT5HhwqXfKq{D@RrK2QO`r&!V!1%B5{bYoOVQhi`Bs4nqAuy-} z$uMTu8GG9G$7g4~GU~NqSxONEAXX*FxRAg_aL%@2`|z+f3g2rO=LYI?;0y4pMq_^m zazw0z(U4xfP*I>2b+2u+Ydj3x=U5-dUm#~Jl(1`T)@{V4yU9c;1&zFKwkqBu8< zSzDT>nUFJH5!UsK&9$}rqAX9PNP0Necw8y+tAm5uB`gWgggt4J{#9TB$vMlIs5+yE z2l+Tk>UrXM9IZX>D2 zxUEj->#e(;=LuF_xN#66vi{ds_*I;bO`}=~pvQ)nxD9tPlq?75&?6-9yVkC4D=Opw zS!M>lwMbtqHkP13$5V@`7eUFHnzk?0b%o)Eu&&TzV+qfg2D2Gy1b-2UG7dB_3}R$2 zZ?lfW*C`Rj&sC?C2{MD3=y|kx+kNVs&c}iPDHDPrY)~aq=X5^ay|b#b5gX&5)ZPK@ z1MOuRaAD5K=r{`BlbLW37`bUtfqha?wt`XO&>cmG3VGBemWmZ&(O3}{jTK?hSP>SD z6=Bg>5f+UVVbS>K21t17NmeMm7r`I6VyvgD&Nm$DM{tiBopU#tiM&meZOc>Yt2pYg-Dp{ z6qx#BpTi|fbvEoSY1n)$an7{>5h@Yf#5LC~OV+7UN^4}UOSIcPo!Yi^?l`cEpSyz6 z3zS*;PweIG1dE4M6~)5L5DBRM9Q8|Zg=?Ifw%Blu<7Ts_hu@d0gvqF0l7%jkEr%L{j#J&RrfC`3GxoI?N^>Cp(2$CRu6}sP>ifvm6Ak0QsQYj@ZsJJz> zX{%w78VT=jc-hSYHwu|M1eMfyt?;fkklWUcl{5d WDUJQ*Q0Q&|0000 + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_library.imageset/BookOpen.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_library.imageset/BookOpen.png deleted file mode 100644 index e512b518aed8c2aa92d7242b3279ca2ebc006516..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 482 zcmV<80UiE{P)t5 z!!Q(v|3us#GeS0?Y*03!6Tk+*8;X!fUl5OU0>T8a0b&Bm1{oo*R4C@yDPWT#XmV_| z)%he_j^kKge2JU`2!bF80y9yo=28t&V~GEvP+=F3$fNSlw#)Vnl-MzSS9YZVzh=NH!akp8Cbxh#Icue2vbIus%u3Xu+l zNQXkCLm|?k5a~Gy9sZjmsq%9)-2y*}`NhiAGnJ3w0Uy$u!900kBi3|1~W?$KPXhMtn*>Biy(lw+I~QPuvVgRJy|AI7dNgLSZwOndFiFCBRtES`@*7yjE71q@&Jisr#XEt%Ok!u=h5AaJjapXhV_2={gIfOOl Y16 + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_briefing.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_search.imageset/Contents.json similarity index 88% rename from apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_briefing.imageset/Contents.json rename to apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_search.imageset/Contents.json index b7cce23dc..a38781902 100644 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_briefing.imageset/Contents.json +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_search.imageset/Contents.json @@ -5,7 +5,7 @@ "scale" : "1x" }, { - "filename" : "Group 1000002644.png", + "filename" : "MagnifyingGlass.png", "idiom" : "universal", "scale" : "2x" }, diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_search.imageset/MagnifyingGlass.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_search.imageset/MagnifyingGlass.png new file mode 100644 index 0000000000000000000000000000000000000000..f41723c949453148be51fd287c96037613d3f1d9 GIT binary patch literal 552 zcmV+@0@wYCP)Dz!S&~3^#xq$PLKSvM3o~A2`xF(ln@dV0E>=)a1O8HC}JN0fP6^MvR;R?1&f32`xW9vDM?Eb9kO%r8XlM5&vE zEOHK^&yCsa@g3kXAQ3aQnbV8GpohOsli4UD-7+Ry=BKIJ%j#;}1EW)>hD8-2ka-S}W?DpWsa98q~JKF^tfZ0d%Edd@-ACUlD3+ZUoG9FA9 z%Sn+9A38mBk;jD$Db#%DXZ3{P0-JvU%>503X^hAxrQkV0+S^?M(2!EYs|2QN$uFA5 zlg`xIl^G-7gQ5+rBUc&=uxp&)YHOY#&u@65yhDkm)A_h)gQN4jjEXim34C`0jDpT+ qlvj^KC(>}(1Kjwon^qbhe^cM=9l^kj)dTea0000UB2*7Vc+MtF{qZtBx00x8M$zh3h7<&!c;9tV<=%#9wlIW|e zRDfoL5IzcjziEvH3NQv!&HkAprqKd8 zb{sxI183m6*^lAy`WQVx_nv7Y;B&OI9&{oL$zVH9G7O*zZDsUp4zA0@t zV~oc_u<>Ioh{Mo}peD6znraodTg}Csb1TN|{$tJJ3(Rmhz=r&`azZ=wqHDn_DAct0 z4v_DL;o(LvnY?DkvR#>4m8I$FF$#hs=_75G^_S%`Q$Z@zoIhGuMYDO0&S0wFb(2zQ p<@wTL+aB0h$`