From 39a50d20b41eb79d401aff95d1dfd2030a503f5b Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Wed, 23 Feb 2022 16:03:59 -0800 Subject: [PATCH] move home feed view into binders --- .../PrimaryContentCategory.swift | 13 +- .../Binders/Scenes/HomeFeedScene.swift | 197 --------------- .../Scenes}/HomeFeedView.swift | 225 ++++++++++++++++-- .../Sources/Views/Images/Images.swift | 2 +- .../HomeFeedCardView.swift | 8 +- .../Sources/Views/SnoozeView.swift | 22 +- 6 files changed, 240 insertions(+), 227 deletions(-) rename apple/OmnivoreKit/Sources/{Views/PrimaryContainerViews => Binders}/PrimaryContentCategory.swift (72%) delete mode 100644 apple/OmnivoreKit/Sources/Binders/Scenes/HomeFeedScene.swift rename apple/OmnivoreKit/Sources/{Views/PrimaryContainerViews => Binders/Scenes}/HomeFeedView.swift (55%) diff --git a/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/PrimaryContentCategory.swift b/apple/OmnivoreKit/Sources/Binders/PrimaryContentCategory.swift similarity index 72% rename from apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/PrimaryContentCategory.swift rename to apple/OmnivoreKit/Sources/Binders/PrimaryContentCategory.swift index 13919d14e..3b133c876 100644 --- a/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/PrimaryContentCategory.swift +++ b/apple/OmnivoreKit/Sources/Binders/PrimaryContentCategory.swift @@ -1,14 +1,15 @@ import SwiftUI +import Views -public enum PrimaryContentCategory: Identifiable, Hashable, Equatable { +enum PrimaryContentCategory: Identifiable, Hashable, Equatable { case feed(viewModel: HomeFeedViewModel) case profile(viewModel: ProfileContainerViewModel) - public static func == (lhs: PrimaryContentCategory, rhs: PrimaryContentCategory) -> Bool { + static func == (lhs: PrimaryContentCategory, rhs: PrimaryContentCategory) -> Bool { lhs.id == rhs.id } - public var id: String { + var id: String { title } @@ -39,11 +40,11 @@ public enum PrimaryContentCategory: Identifiable, Hashable, Equatable { } } - public var listLabel: some View { + var listLabel: some View { Label { Text(title) } icon: { image.renderingMode(.template) } } - @ViewBuilder public var destinationView: some View { + @ViewBuilder var destinationView: some View { switch self { case let .feed(viewModel: viewModel): HomeFeedView(viewModel: viewModel) @@ -52,7 +53,7 @@ public enum PrimaryContentCategory: Identifiable, Hashable, Equatable { } } - public func hash(into hasher: inout Hasher) { + func hash(into hasher: inout Hasher) { hasher.combine(id) } } diff --git a/apple/OmnivoreKit/Sources/Binders/Scenes/HomeFeedScene.swift b/apple/OmnivoreKit/Sources/Binders/Scenes/HomeFeedScene.swift deleted file mode 100644 index 5f4541645..000000000 --- a/apple/OmnivoreKit/Sources/Binders/Scenes/HomeFeedScene.swift +++ /dev/null @@ -1,197 +0,0 @@ -import Models -import Services -import SwiftUI -import UserNotifications -import Utils -import Views - -extension HomeFeedViewModel { - static func make(services: Services) -> HomeFeedViewModel { - let viewModel = HomeFeedViewModel { feedItem in - LinkItemDetailViewModel.make(feedItem: feedItem, services: services) - } - - if UIDevice.isIPhone { - viewModel.profileContainerViewModel = ProfileContainerViewModel.make(services: services) - } - - viewModel.bind(services: services) - viewModel.loadItems(dataService: services.dataService, searchQuery: nil, isRefresh: false) - return viewModel - } - - func bind(services: Services) { - performActionSubject.sink { [weak self] action in - switch action { - case let .refreshItems(query: query): - self?.loadItems(dataService: services.dataService, searchQuery: query, isRefresh: true) - case let .loadItems(query): - self?.loadItems(dataService: services.dataService, searchQuery: query, isRefresh: false) - case let .archive(linkId): - self?.setLinkArchived(dataService: services.dataService, linkId: linkId, archived: true) - case let .unarchive(linkId): - self?.setLinkArchived(dataService: services.dataService, linkId: linkId, archived: false) - case let .remove(linkId): - self?.removeLink(dataService: services.dataService, linkId: linkId) - case let .snooze(linkId, until, successMessage): - self?.snoozeUntil( - dataService: services.dataService, - linkId: linkId, - until: until, - successMessage: successMessage - ) - } - } - .store(in: &subscriptions) - } - - private func loadItems(dataService: DataService, searchQuery: String?, isRefresh: Bool) { - // Clear offline highlights since we'll be populating new FeedItems with the correct highlights set - dataService.clearHighlights() - - let thisSearchIdx = searchIdx - searchIdx += 1 - - isLoading = true - startNetworkActivityIndicator() - - // Cache the viewer - if dataService.currentViewer == nil { - dataService.viewerPublisher().sink( - receiveCompletion: { _ in }, - receiveValue: { _ in } - ) - .store(in: &subscriptions) - } - - dataService.libraryItemsPublisher( - limit: 10, - sortDescending: true, - searchQuery: searchQuery, - cursor: isRefresh ? nil : cursor - ) - .sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(error) = completion else { return } - self?.isLoading = false - stopNetworkActivityIndicator() - print(error) - }, - receiveValue: { [weak self] result in - // Search results aren't guaranteed to return in order so this - // will discard old results that are returned while a user is typing. - // For example if a user types 'Canucks', often the search results - // for 'C' are returned after 'Canucks' because it takes the backend - // much longer to compute. - if thisSearchIdx > 0, thisSearchIdx <= self?.receivedIdx ?? 0 { - return - } - self?.items = isRefresh ? result.items : (self?.items ?? []) + result.items - self?.isLoading = false - self?.receivedIdx = thisSearchIdx - self?.cursor = result.cursor - stopNetworkActivityIndicator() - } - ) - .store(in: &subscriptions) - } - - private func setLinkArchived(dataService: DataService, linkId: String, archived: Bool) { - isLoading = true - startNetworkActivityIndicator() - - // First remove the link from the internal list, - // then make a call to remove it. The isLoading block should - // prevent our local change from being overwritten, but we - // might need to cache a local list of archived links - if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { - items.remove(at: itemIndex) - } - - dataService.archiveLinkPublisher(itemID: linkId, archived: archived) - .sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(error) = completion else { return } - self?.isLoading = false - stopNetworkActivityIndicator() - print(error) - NSNotification.operationFailed(message: archived ? "Failed to archive link" : "Failed to unarchive link") - }, - receiveValue: { [weak self] _ in - self?.isLoading = false - stopNetworkActivityIndicator() - NSNotification.operationSuccess(message: archived ? "Link archived" : "Link moved to Inbox") - } - ) - .store(in: &subscriptions) - } - - private func removeLink(dataService: DataService, linkId: String) { - isLoading = true - startNetworkActivityIndicator() - - if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { - items.remove(at: itemIndex) - } - - dataService.removeLinkPublisher(itemID: linkId) - .sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(error) = completion else { return } - self?.isLoading = false - stopNetworkActivityIndicator() - print(error) - NSNotification.operationFailed(message: "Failed to remove link") - }, - receiveValue: { [weak self] _ in - self?.isLoading = false - stopNetworkActivityIndicator() - NSNotification.operationSuccess(message: "Link removed") - } - ) - .store(in: &subscriptions) - } - - private func snoozeUntil(dataService: DataService, linkId: String, until: Date, successMessage: String?) { - isLoading = true - startNetworkActivityIndicator() - - if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { - items.remove(at: itemIndex) - } - - dataService.createReminderPublisher( - reminderItemId: .link(id: linkId), - remindAt: until - ) - .sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(error) = completion else { return } - self?.isLoading = false - stopNetworkActivityIndicator() - print(error) - NSNotification.operationFailed(message: "Failed to snooze") - }, - receiveValue: { [weak self] _ in - self?.isLoading = false - stopNetworkActivityIndicator() - if let message = successMessage { - NSNotification.operationSuccess(message: message) - } - } - ) - .store(in: &subscriptions) - } -} - -private func startNetworkActivityIndicator() { - #if os(iOS) - UIApplication.shared.isNetworkActivityIndicatorVisible = true - #endif -} - -private func stopNetworkActivityIndicator() { - #if os(iOS) - UIApplication.shared.isNetworkActivityIndicatorVisible = false - #endif -} diff --git a/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/HomeFeedView.swift b/apple/OmnivoreKit/Sources/Binders/Scenes/HomeFeedView.swift similarity index 55% rename from apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/HomeFeedView.swift rename to apple/OmnivoreKit/Sources/Binders/Scenes/HomeFeedView.swift index cea24590e..d93a78a13 100644 --- a/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/HomeFeedView.swift +++ b/apple/OmnivoreKit/Sources/Binders/Scenes/HomeFeedView.swift @@ -1,24 +1,219 @@ import Combine import Models +import Services import SwiftUI +import UserNotifications import Utils +import Views -public final class HomeFeedViewModel: ObservableObject { +extension HomeFeedViewModel { + static func make(services: Services) -> HomeFeedViewModel { + let viewModel = HomeFeedViewModel { feedItem in + LinkItemDetailViewModel.make(feedItem: feedItem, services: services) + } + + if UIDevice.isIPhone { + viewModel.profileContainerViewModel = ProfileContainerViewModel.make(services: services) + } + + viewModel.bind(services: services) + viewModel.loadItems(dataService: services.dataService, searchQuery: nil, isRefresh: false) + return viewModel + } + + func bind(services: Services) { + performActionSubject.sink { [weak self] action in + switch action { + case let .refreshItems(query: query): + self?.loadItems(dataService: services.dataService, searchQuery: query, isRefresh: true) + case let .loadItems(query): + self?.loadItems(dataService: services.dataService, searchQuery: query, isRefresh: false) + case let .archive(linkId): + self?.setLinkArchived(dataService: services.dataService, linkId: linkId, archived: true) + case let .unarchive(linkId): + self?.setLinkArchived(dataService: services.dataService, linkId: linkId, archived: false) + case let .remove(linkId): + self?.removeLink(dataService: services.dataService, linkId: linkId) + case let .snooze(linkId, until, successMessage): + self?.snoozeUntil( + dataService: services.dataService, + linkId: linkId, + until: until, + successMessage: successMessage + ) + } + } + .store(in: &subscriptions) + } + + private func loadItems(dataService: DataService, searchQuery: String?, isRefresh: Bool) { + // Clear offline highlights since we'll be populating new FeedItems with the correct highlights set + dataService.clearHighlights() + + let thisSearchIdx = searchIdx + searchIdx += 1 + + isLoading = true + startNetworkActivityIndicator() + + // Cache the viewer + if dataService.currentViewer == nil { + dataService.viewerPublisher().sink( + receiveCompletion: { _ in }, + receiveValue: { _ in } + ) + .store(in: &subscriptions) + } + + dataService.libraryItemsPublisher( + limit: 10, + sortDescending: true, + searchQuery: searchQuery, + cursor: isRefresh ? nil : cursor + ) + .sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(error) = completion else { return } + self?.isLoading = false + stopNetworkActivityIndicator() + print(error) + }, + receiveValue: { [weak self] result in + // Search results aren't guaranteed to return in order so this + // will discard old results that are returned while a user is typing. + // For example if a user types 'Canucks', often the search results + // for 'C' are returned after 'Canucks' because it takes the backend + // much longer to compute. + if thisSearchIdx > 0, thisSearchIdx <= self?.receivedIdx ?? 0 { + return + } + self?.items = isRefresh ? result.items : (self?.items ?? []) + result.items + self?.isLoading = false + self?.receivedIdx = thisSearchIdx + self?.cursor = result.cursor + stopNetworkActivityIndicator() + } + ) + .store(in: &subscriptions) + } + + private func setLinkArchived(dataService: DataService, linkId: String, archived: Bool) { + isLoading = true + startNetworkActivityIndicator() + + // First remove the link from the internal list, + // then make a call to remove it. The isLoading block should + // prevent our local change from being overwritten, but we + // might need to cache a local list of archived links + if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { + items.remove(at: itemIndex) + } + + dataService.archiveLinkPublisher(itemID: linkId, archived: archived) + .sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(error) = completion else { return } + self?.isLoading = false + stopNetworkActivityIndicator() + print(error) + NSNotification.operationFailed(message: archived ? "Failed to archive link" : "Failed to unarchive link") + }, + receiveValue: { [weak self] _ in + self?.isLoading = false + stopNetworkActivityIndicator() + NSNotification.operationSuccess(message: archived ? "Link archived" : "Link moved to Inbox") + } + ) + .store(in: &subscriptions) + } + + private func removeLink(dataService: DataService, linkId: String) { + isLoading = true + startNetworkActivityIndicator() + + if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { + items.remove(at: itemIndex) + } + + dataService.removeLinkPublisher(itemID: linkId) + .sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(error) = completion else { return } + self?.isLoading = false + stopNetworkActivityIndicator() + print(error) + NSNotification.operationFailed(message: "Failed to remove link") + }, + receiveValue: { [weak self] _ in + self?.isLoading = false + stopNetworkActivityIndicator() + NSNotification.operationSuccess(message: "Link removed") + } + ) + .store(in: &subscriptions) + } + + private func snoozeUntil(dataService: DataService, linkId: String, until: Date, successMessage: String?) { + isLoading = true + startNetworkActivityIndicator() + + if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { + items.remove(at: itemIndex) + } + + dataService.createReminderPublisher( + reminderItemId: .link(id: linkId), + remindAt: until + ) + .sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(error) = completion else { return } + self?.isLoading = false + stopNetworkActivityIndicator() + print(error) + NSNotification.operationFailed(message: "Failed to snooze") + }, + receiveValue: { [weak self] _ in + self?.isLoading = false + stopNetworkActivityIndicator() + if let message = successMessage { + NSNotification.operationSuccess(message: message) + } + } + ) + .store(in: &subscriptions) + } +} + +private func startNetworkActivityIndicator() { + #if os(iOS) + UIApplication.shared.isNetworkActivityIndicatorVisible = true + #endif +} + +private func stopNetworkActivityIndicator() { + #if os(iOS) + UIApplication.shared.isNetworkActivityIndicatorVisible = false + #endif +} + +// TODO: remove this view model +final class HomeFeedViewModel: ObservableObject { let detailViewModelCreator: (FeedItem) -> LinkItemDetailViewModel var currentDetailViewModel: LinkItemDetailViewModel? - public var profileContainerViewModel: ProfileContainerViewModel? + var profileContainerViewModel: ProfileContainerViewModel? - @Published public var items = [FeedItem]() - @Published public var isLoading = false - @Published public var showPushNotificationPrimer = false - public var cursor: String? + @Published var items = [FeedItem]() + @Published var isLoading = false + @Published var showPushNotificationPrimer = false + var cursor: String? // These are used to make sure we handle search result // responses in the right order - public var searchIdx = 0 - public var receivedIdx = 0 + var searchIdx = 0 + var receivedIdx = 0 - public enum Action { + enum Action { case refreshItems(query: String) case loadItems(query: String) case archive(linkId: String) @@ -27,10 +222,10 @@ public final class HomeFeedViewModel: ObservableObject { case snooze(linkId: String, until: Date, successMessage: String?) } - public var subscriptions = Set() - public let performActionSubject = PassthroughSubject() + var subscriptions = Set() + let performActionSubject = PassthroughSubject() - public init(detailViewModelCreator: @escaping (FeedItem) -> LinkItemDetailViewModel) { + init(detailViewModelCreator: @escaping (FeedItem) -> LinkItemDetailViewModel) { self.detailViewModelCreator = detailViewModelCreator } @@ -50,7 +245,7 @@ public final class HomeFeedViewModel: ObservableObject { } } -public struct HomeFeedView: View { +struct HomeFeedView: View { @ObservedObject private var viewModel: HomeFeedViewModel @State private var selectedLinkItem: FeedItem? @State private var searchQuery = "" @@ -59,7 +254,7 @@ public struct HomeFeedView: View { @State private var snoozePresented = false @State private var itemToSnooze: FeedItem? - public init(viewModel: HomeFeedViewModel) { + init(viewModel: HomeFeedViewModel) { self.viewModel = viewModel } @@ -255,7 +450,7 @@ public struct HomeFeedView: View { } } - public var body: some View { + var body: some View { #if os(iOS) if UIDevice.isIPhone, let profileContainerViewModel = viewModel.profileContainerViewModel { NavigationView { diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.swift b/apple/OmnivoreKit/Sources/Views/Images/Images.swift index 9f9526a5a..e612a4afa 100644 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.swift +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.swift @@ -1,6 +1,6 @@ import SwiftUI -extension Image { +public extension Image { static var smallOmnivoreLogo: Image { Image("_smallOmnivoreLogo", bundle: .module) } static var omnivoreTitleLogo: Image { Image("_omnivoreTitleLogo", bundle: .module) } static var readingIllustration: Image { Image("_readingIllustration", bundle: .module) } diff --git a/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/HomeFeedCardView.swift b/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/HomeFeedCardView.swift index ff7858469..310759e6c 100644 --- a/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/HomeFeedCardView.swift +++ b/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/HomeFeedCardView.swift @@ -1,10 +1,14 @@ import Models import SwiftUI -struct FeedCard: View { +public struct FeedCard: View { let item: FeedItem - var body: some View { + public init(item: FeedItem) { + self.item = item + } + + public var body: some View { HStack(alignment: .top, spacing: 6) { VStack(alignment: .leading, spacing: 6) { Text(item.title) diff --git a/apple/OmnivoreKit/Sources/Views/SnoozeView.swift b/apple/OmnivoreKit/Sources/Views/SnoozeView.swift index 7901412cf..8c23a926e 100644 --- a/apple/OmnivoreKit/Sources/Views/SnoozeView.swift +++ b/apple/OmnivoreKit/Sources/Views/SnoozeView.swift @@ -1,12 +1,22 @@ import Models import SwiftUI -struct SnoozeView: View { +public struct SnoozeView: View { @Binding var snoozePresented: Bool @Binding var itemToSnooze: FeedItem? let snoozeAction: (SnoozeActionParams) -> Void - var body: some View { + public init( + snoozePresented: Binding, + itemToSnooze: Binding, + snoozeAction: @escaping (SnoozeActionParams) -> Void + ) { + self._snoozePresented = snoozePresented + self._itemToSnooze = itemToSnooze + self.snoozeAction = snoozeAction + } + + public var body: some View { VStack { Spacer() @@ -42,10 +52,10 @@ struct SnoozeView: View { } } -struct SnoozeActionParams { - let feedItemId: String - let snoozeUntilDate: Date - let successMessage: String? +public struct SnoozeActionParams { + public let feedItemId: String + public let snoozeUntilDate: Date + public let successMessage: String? } private struct SnoozeIconButtonView: View {