move home feed view into binders

This commit is contained in:
Satindar Dhillon
2022-02-23 16:03:59 -08:00
parent 3289c5bcbb
commit 39a50d20b4
6 changed files with 240 additions and 227 deletions

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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<AnyCancellable>()
public let performActionSubject = PassthroughSubject<Action, Never>()
var subscriptions = Set<AnyCancellable>()
let performActionSubject = PassthroughSubject<Action, Never>()
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 {

View File

@ -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) }

View File

@ -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)

View File

@ -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<Bool>,
itemToSnooze: Binding<FeedItem?>,
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 {