diff --git a/Modules/Package.swift b/Modules/Package.swift index 80d4ba337bf8..6407f9793dae 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -43,7 +43,7 @@ let package = Package( .package(url: "https://github.com/wordpress-mobile/MediaEditor-iOS", branch: "task/spm-support"), .package(url: "https://github.com/wordpress-mobile/NSObject-SafeExpectations", from: "0.0.6"), .package(url: "https://github.com/wordpress-mobile/NSURL-IDN", branch: "trunk"), - .package(url: "https://github.com/wordpress-mobile/WordPressKit-iOS", branch: "wpios-edition"), + .package(url: "https://github.com/wordpress-mobile/WordPressKit-iOS", branch: "task/reader-discover"), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), // We can't use wordpress-rs branches nor commits here. Only tags work. .package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-swift-20240813"), diff --git a/Modules/Sources/WordPressShared/Utility/ManagedObjectsObserver.swift b/Modules/Sources/WordPressShared/Utility/ManagedObjectsObserver.swift new file mode 100644 index 000000000000..27d2ac48b862 --- /dev/null +++ b/Modules/Sources/WordPressShared/Utility/ManagedObjectsObserver.swift @@ -0,0 +1,38 @@ +import Foundation +import CoreData +import Combine + +public final class ManagedObjectsObserver: NSObject, NSFetchedResultsControllerDelegate { + @Published private(set) public var objects: [T] = [] + + private let controller: NSFetchedResultsController + + public convenience init( + predicate: NSPredicate, + sortDescriptors: [SortDescriptor], + context: NSManagedObjectContext + ) { + let request = NSFetchRequest(entityName: T.entity().name ?? "") + request.predicate = predicate + request.sortDescriptors = sortDescriptors.map(NSSortDescriptor.init) + self.init(request: request, context: context) + } + + public init( + request: NSFetchRequest, + context: NSManagedObjectContext, + cacheName: String? = nil + ) { + self.controller = NSFetchedResultsController(fetchRequest: request, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: cacheName) + super.init() + + try? controller.performFetch() + objects = controller.fetchedObjects ?? [] + + controller.delegate = self + } + + public func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + objects = self.controller.fetchedObjects ?? [] + } +} diff --git a/Modules/Sources/WordPressUI/Extensions/UITextView+Extensions.swift b/Modules/Sources/WordPressUI/Extensions/UITextView+Extensions.swift new file mode 100644 index 000000000000..e9bb2867837a --- /dev/null +++ b/Modules/Sources/WordPressUI/Extensions/UITextView+Extensions.swift @@ -0,0 +1,14 @@ +import UIKit + +extension UITextView { + /// Creates a text view that behaves like a non-editable multiline label + /// but supports interaction and other text view features. + public static func makeLabel() -> UITextView { + let textView = UITextView() + textView.isScrollEnabled = false + textView.isEditable = false + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + return textView + } +} diff --git a/Modules/Sources/WordPressUI/Extensions/UIView+AutoLayout.swift b/Modules/Sources/WordPressUI/Extensions/UIView+AutoLayout.swift new file mode 100644 index 000000000000..ee82e9a6d2e4 --- /dev/null +++ b/Modules/Sources/WordPressUI/Extensions/UIView+AutoLayout.swift @@ -0,0 +1,116 @@ +import UIKit +import SwiftUI + +extension UIView { + /// Pins edges of the view to the edges of the given target view or layout + /// guide. By default, pins to the superview. + /// + /// The view also gets enabled for Auto Layout by setting + /// `translatesAutoresizingMaskIntoConstraints` to `false`. + /// + /// Example uage: + /// + /// ```swift + /// subview.pinEdges() // to superview + /// subview.pinEdges(to: superview.safeAreaLayoutGuide) + /// ``` + @discardableResult + public func pinEdges( + _ edges: Edge.Set = .all, + to target: AutoLayoutItem? = nil, + insets: UIEdgeInsets = .zero, + relation: AutoLayoutPinEdgesRelation = .equal, + priority: UILayoutPriority? = nil + ) -> [NSLayoutConstraint] { + guard let target = target ?? superview else { + assertionFailure("view has to be installed in the view hierarchy") + return [] + } + translatesAutoresizingMaskIntoConstraints = false + +#if DEBUG + if let target = target as? UIView { + assert(!target.isDescendant(of: self), "The target view can't be a descendant for the view") + } +#endif + + var constraints: [NSLayoutConstraint] = [] + + func pin(_ edge: Edge.Set, _ closure: @autoclosure () -> NSLayoutConstraint) { + guard edges.contains(edge) else { return } + constraints.append(closure()) + } + + switch relation { + case .equal: + pin(.top, topAnchor.constraint(equalTo: target.topAnchor, constant: insets.top)) + pin(.trailing, trailingAnchor.constraint(equalTo: target.trailingAnchor, constant: -insets.right)) + pin(.bottom, bottomAnchor.constraint(equalTo: target.bottomAnchor, constant: -insets.bottom)) + pin(.leading, leadingAnchor.constraint(equalTo: target.leadingAnchor, constant: insets.left)) + case .lessThanOrEqual: + pin(.top, topAnchor.constraint(greaterThanOrEqualTo: target.topAnchor, constant: insets.top)) + pin(.trailing, trailingAnchor.constraint(lessThanOrEqualTo: target.trailingAnchor, constant: -insets.right)) + pin(.bottom, bottomAnchor.constraint(lessThanOrEqualTo: target.bottomAnchor, constant: -insets.bottom)) + pin(.leading, leadingAnchor.constraint(greaterThanOrEqualTo: target.leadingAnchor, constant: insets.left)) + } + + if let priority { + for constraint in constraints { + constraint.priority = priority + } + } + + NSLayoutConstraint.activate(constraints) + return constraints + } + + /// Pins the view to the center of the given container. By default, + /// pins to the superview. + @discardableResult + public func pinCenter( + to target: AutoLayoutItem? = nil, + offset: UIOffset = .zero, + priority: UILayoutPriority? = nil + ) -> [NSLayoutConstraint] { + guard let target = target ?? superview else { + assertionFailure("view has to be installed in the view hierarchy") + return [] + } + translatesAutoresizingMaskIntoConstraints = false + + let constraints = [ + centerXAnchor.constraint(equalTo: target.centerXAnchor, constant: offset.horizontal), + centerYAnchor.constraint(equalTo: target.centerYAnchor, constant: offset.vertical), + ] + + if let priority { + for constraint in constraints { + constraint.priority = priority + } + } + + NSLayoutConstraint.activate(constraints) + return constraints + } +} + +public protocol AutoLayoutItem { + var leadingAnchor: NSLayoutXAxisAnchor { get } + var trailingAnchor: NSLayoutXAxisAnchor { get } + var leftAnchor: NSLayoutXAxisAnchor { get } + var rightAnchor: NSLayoutXAxisAnchor { get } + var topAnchor: NSLayoutYAxisAnchor { get } + var bottomAnchor: NSLayoutYAxisAnchor { get } + var widthAnchor: NSLayoutDimension { get } + var heightAnchor: NSLayoutDimension { get } + var centerXAnchor: NSLayoutXAxisAnchor { get } + var centerYAnchor: NSLayoutYAxisAnchor { get } +} + +public enum AutoLayoutPinEdgesRelation { + case equal + case lessThanOrEqual +} + +extension UIView: AutoLayoutItem {} +extension UILayoutGuide: AutoLayoutItem {} diff --git a/Modules/Sources/WordPressUI/Extensions/UIView+Helpers.swift b/Modules/Sources/WordPressUI/Extensions/UIView+Helpers.swift index 8e31fdb0753a..f4e0f1ae98a3 100644 --- a/Modules/Sources/WordPressUI/Extensions/UIView+Helpers.swift +++ b/Modules/Sources/WordPressUI/Extensions/UIView+Helpers.swift @@ -1,8 +1,8 @@ import Foundation import UIKit -// MARK: - UIView Helpers -// +// MARK: - UIView (Soft-Deprecated) + extension UIView { @objc public func pinSubviewAtCenter(_ subview: UIView) { @@ -89,13 +89,4 @@ extension UIView { @objc public func userInterfaceLayoutDirection() -> UIUserInterfaceLayoutDirection { return UIView.userInterfaceLayoutDirection(for: semanticContentAttribute) } - - public func changeLayoutMargins(top: CGFloat? = nil, left: CGFloat? = nil, bottom: CGFloat? = nil, right: CGFloat? = nil) { - let top = top ?? layoutMargins.top - let left = left ?? layoutMargins.left - let bottom = bottom ?? layoutMargins.bottom - let right = right ?? layoutMargins.right - - layoutMargins = UIEdgeInsets(top: top, left: left, bottom: bottom, right: right) - } } diff --git a/Modules/Tests/WordPressUITests/Extensions/UIView+ChangeLayoutMarginsTests.swift b/Modules/Tests/WordPressUITests/Extensions/UIView+ChangeLayoutMarginsTests.swift deleted file mode 100644 index 53acacc1e879..000000000000 --- a/Modules/Tests/WordPressUITests/Extensions/UIView+ChangeLayoutMarginsTests.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation -import XCTest - -@testable import WordPressUI - -class UIViewChangeLayoutMarginSTests: XCTestCase { - - var view: UIView! - - override func setUp() { - view = UIView() - view.layoutMargins = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) - } - - func testChangeOnlyTopLayoutMargin() { - view.changeLayoutMargins(top: 10) - - XCTAssertEqual(view.layoutMargins.top, 10) - XCTAssertEqual(view.layoutMargins.left, 5) - XCTAssertEqual(view.layoutMargins.bottom, 5) - XCTAssertEqual(view.layoutMargins.right, 5) - } - - func testChangeOnlyLeftLayoutMargin() { - view.changeLayoutMargins(left: 10) - - XCTAssertEqual(view.layoutMargins.top, 5) - XCTAssertEqual(view.layoutMargins.left, 10) - XCTAssertEqual(view.layoutMargins.bottom, 5) - XCTAssertEqual(view.layoutMargins.right, 5) - } - - func testChangeOnlyBottomLayoutMargin() { - view.changeLayoutMargins(bottom: 10) - - XCTAssertEqual(view.layoutMargins.top, 5) - XCTAssertEqual(view.layoutMargins.left, 5) - XCTAssertEqual(view.layoutMargins.bottom, 10) - XCTAssertEqual(view.layoutMargins.right, 5) - } - - func testChangeOnlyRightLayoutMargin() { - view.changeLayoutMargins(right: 10) - - XCTAssertEqual(view.layoutMargins.top, 5) - XCTAssertEqual(view.layoutMargins.left, 5) - XCTAssertEqual(view.layoutMargins.bottom, 5) - XCTAssertEqual(view.layoutMargins.right, 10) - } -} diff --git a/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved index e4572bea8db4..5b43b8f0078e 100644 --- a/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -381,8 +381,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/WordPressKit-iOS", "state" : { - "branch" : "wpios-edition", - "revision" : "c3eeb90e7a4f3664f85ff53f1fef009cda17d5b6" + "branch" : "task/reader-discover", + "revision" : "a5dfe35cd92e4df8bdd971edd162ecf02b7d0a29" } }, { diff --git a/WordPress/Classes/Extensions/Font/UIFont+Weight.swift b/WordPress/Classes/Extensions/Font/UIFont+Weight.swift index a8df631c9e16..392edc098891 100644 --- a/WordPress/Classes/Extensions/Font/UIFont+Weight.swift +++ b/WordPress/Classes/Extensions/Font/UIFont+Weight.swift @@ -22,9 +22,8 @@ extension UIFont { return UIFont(descriptor: descriptor, size: 0) } - private func withWeight(_ weight: UIFont.Weight) -> UIFont { + func withWeight(_ weight: UIFont.Weight) -> UIFont { let descriptor = fontDescriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: weight]]) - return UIFont(descriptor: descriptor, size: 0) } } diff --git a/WordPress/Classes/Models/ReaderTagTopic.swift b/WordPress/Classes/Models/ReaderTagTopic.swift index b9cc1974b08a..9151484d576d 100644 --- a/WordPress/Classes/Models/ReaderTagTopic.swift +++ b/WordPress/Classes/Models/ReaderTagTopic.swift @@ -58,3 +58,7 @@ import Foundation showInMenu = (following || isRecommended) } } + +extension ReaderTagTopic { + static let dailyPromptTag = "dailyprompt" +} diff --git a/WordPress/Classes/Services/ReaderCardService.swift b/WordPress/Classes/Services/ReaderCardService.swift index 2fb1dd55c235..47b2c016b3ed 100644 --- a/WordPress/Classes/Services/ReaderCardService.swift +++ b/WordPress/Classes/Services/ReaderCardService.swift @@ -1,8 +1,9 @@ import Foundation +import WordPressKit protocol ReaderCardServiceRemote { - - func fetchStreamCards(for topics: [String], + func fetchStreamCards(stream: ReaderStream, + for topics: [String], page: String?, sortingOption: ReaderSortingOption, refreshCount: Int?, @@ -10,18 +11,13 @@ protocol ReaderCardServiceRemote { success: @escaping ([RemoteReaderCard], String?) -> Void, failure: @escaping (Error) -> Void) - func fetchCards(for topics: [String], - page: String?, - sortingOption: ReaderSortingOption, - refreshCount: Int?, - success: @escaping ([RemoteReaderCard], String?) -> Void, - failure: @escaping (Error) -> Void) - } extension ReaderPostServiceRemote: ReaderCardServiceRemote { } class ReaderCardService { + private let stream: ReaderStream + private let sorting: ReaderSortingOption private let service: ReaderCardServiceRemote private let coreDataStack: CoreDataStack @@ -35,10 +31,14 @@ class ReaderCardService { /// Used only internally to order the cards private var pageNumber = 1 - init(service: ReaderCardServiceRemote = ReaderPostServiceRemote.withDefaultApi(), + init(stream: ReaderStream = .discover, + sorting: ReaderSortingOption = .noSorting, + service: ReaderCardServiceRemote = ReaderPostServiceRemote.withDefaultApi(), coreDataStack: CoreDataStack = ContextManager.shared, followedInterestsService: ReaderFollowedInterestsService? = nil, siteInfoService: ReaderSiteInfoService? = nil) { + self.stream = stream + self.sorting = sorting self.service = service self.coreDataStack = coreDataStack self.followedInterestsService = followedInterestsService ?? ReaderTopicService(coreDataStack: coreDataStack) @@ -72,7 +72,7 @@ class ReaderCardService { self.coreDataStack.performAndSave({ context in if isFirstPage { self.pageNumber = 1 - self.removeAllCards(in: context) + ReaderCardService.removeAllCards(in: context) } else { self.pageNumber += 1 } @@ -114,33 +114,31 @@ class ReaderCardService { failure(error) } - if RemoteFeatureFlag.readerDiscoverEndpoint.enabled() { - self.service.fetchStreamCards(for: slugs, - page: self.pageHandle(isFirstPage: isFirstPage), - sortingOption: .noSorting, - refreshCount: refreshCount, - count: nil, - success: success, - failure: failure) - } else { - self.service.fetchCards(for: slugs, - page: self.pageHandle(isFirstPage: isFirstPage), - sortingOption: .noSorting, - refreshCount: refreshCount, - success: success, - failure: failure) - } + self.service.fetchStreamCards( + stream: self.stream, + for: slugs, + page: self.pageHandle(isFirstPage: isFirstPage), + sortingOption: self.sorting, + refreshCount: refreshCount, + count: nil, + success: success, + failure: failure + ) } } /// Remove all cards and saves the context func clean() { - coreDataStack.performAndSave { context in - self.removeAllCards(in: context) + ReaderCardService.removeAllCards(on: coreDataStack) + } + + static func removeAllCards(on stack: CoreDataStack = ContextManager.shared) { + stack.performAndSave { context in + removeAllCards(in: context) } } - private func removeAllCards(in context: NSManagedObjectContext) { + private static func removeAllCards(in context: NSManagedObjectContext) { let fetchRequest = NSFetchRequest(entityName: ReaderCard.classNameWithoutNamespaces()) fetchRequest.returnsObjectsAsFaults = false diff --git a/WordPress/Classes/System/ReaderPresenter.swift b/WordPress/Classes/System/ReaderPresenter.swift index c0d59c8050e5..967d99cb40bd 100644 --- a/WordPress/Classes/System/ReaderPresenter.swift +++ b/WordPress/Classes/System/ReaderPresenter.swift @@ -111,7 +111,7 @@ final class ReaderPresenter: NSObject, SplitViewDisplayable { case .recent, .discover, .likes: if let topic = screen.topicType.flatMap(sidebarViewModel.getTopic) { if screen == .discover { - return ReaderCardsStreamViewController.controller(topic: topic) + return ReaderDiscoverViewController(topic: topic) } else { return ReaderStreamViewController.controllerWithTopic(topic) } diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift index 7ffc738c2003..c8e43ad4a768 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift @@ -320,6 +320,10 @@ import Foundation case postRepositoryConflictEncountered case postRepositoryPostsFetchFailed + // Reader: Discover + case readerDiscoverChannelSelected + case readerDiscoverEditInterestsTapped + // Reader: Filter Sheet case readerFilterSheetDisplayed case readerFilterSheetDismissed @@ -1198,6 +1202,12 @@ import Foundation case .postRepositoryPostsFetchFailed: return "post_repository_posts_fetch_failed" + // Reader: Discover + case .readerDiscoverChannelSelected: + return "reader_discover_channel_selected" + case .readerDiscoverEditInterestsTapped: + return "reader_discover_edit_interests_tapped" + // Reader: Filter Sheet case .readerFilterSheetDisplayed: return "reader_filter_sheet_displayed" diff --git a/WordPress/Classes/Utility/BuildInformation/RemoteFeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/RemoteFeatureFlag.swift index 4ec98147bd8f..fbb2123628d2 100644 --- a/WordPress/Classes/Utility/BuildInformation/RemoteFeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/RemoteFeatureFlag.swift @@ -26,7 +26,6 @@ enum RemoteFeatureFlag: Int, CaseIterable { case wordPressSotWCard case inAppRating case siteMonitoring - case readerDiscoverEndpoint case readingPreferences case readingPreferencesFeedback case readerAnnouncementCard @@ -84,8 +83,6 @@ enum RemoteFeatureFlag: Int, CaseIterable { return false case .siteMonitoring: return false - case .readerDiscoverEndpoint: - return true case .readingPreferences: return true case .readingPreferencesFeedback: @@ -152,8 +149,6 @@ enum RemoteFeatureFlag: Int, CaseIterable { return "in_app_rating_and_feedback" case .siteMonitoring: return "site_monitoring" - case .readerDiscoverEndpoint: - return "reader_discover_new_endpoint" case .readingPreferences: return "reading_preferences" case .readingPreferencesFeedback: @@ -219,8 +214,6 @@ enum RemoteFeatureFlag: Int, CaseIterable { return "In-App Rating and Feedback" case .siteMonitoring: return "Site Monitoring" - case .readerDiscoverEndpoint: - return "Reader Discover New Endpoint" case .readingPreferences: return "Reading Preferences" case .readingPreferencesFeedback: diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift index 27df0c822ca6..88242cfcec3a 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift @@ -179,7 +179,7 @@ class DashboardPromptsCardCell: UICollectionViewCell, Reusable { let promptID = prompt?.promptID else { return } - let tagName = "\(Constants.dailyPromptTag)-\(promptID)" + let tagName = "\(ReaderTagTopic.dailyPromptTag)-\(promptID)" RootViewCoordinator.sharedPresenter.showReader(path: .makeWithTagName(tagName)) WPAnalytics.track(.promptsOtherAnswersTapped) } @@ -582,7 +582,6 @@ private extension DashboardPromptsCardCell { static let exampleAnswerCount = 19 static let cardFrameConstraintPriority = UILayoutPriority(999) static let skippedPromptsUDKey = "wp_skipped_blogging_prompts" - static let dailyPromptTag = "dailyprompt" } // MARK: Contextual Menu diff --git a/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsTableViewModel.swift b/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsTableViewModel.swift index 419a598e8333..deae1d254ad8 100644 --- a/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsTableViewModel.swift +++ b/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsTableViewModel.swift @@ -54,6 +54,14 @@ extension ReaderTagsTableViewModel: WPTableViewHandlerDelegate { // MARK: - Discover more topics footer + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + nil + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + CGFloat.leastNormalMagnitude + } + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { guard section == 0 else { return CGFloat.leastNormalMagnitude diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderDiscoverHeaderView.swift b/WordPress/Classes/ViewRelated/Reader/ReaderDiscoverHeaderView.swift new file mode 100644 index 000000000000..cae921d57e8c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderDiscoverHeaderView.swift @@ -0,0 +1,215 @@ +import UIKit + +protocol ReaderDiscoverHeaderViewDelegate: AnyObject { + func readerDiscoverHeaderView(_ view: ReaderDiscoverHeaderView, didChangeSelection selection: ReaderDiscoverChannel) +} + +final class ReaderDiscoverHeaderView: UIView, UITextViewDelegate { + private let titleView = ReaderStreamTitleView() + private let channelsStackView = UIStackView(spacing: 8, []) + private var channelViews: [ReaderDiscoverChannelView] = [] + + private var selectedChannel: ReaderDiscoverChannel? + + weak var delegate: ReaderDiscoverHeaderViewDelegate? + + override init(frame: CGRect) { + super.init(frame: frame) + + let scrollView = UIScrollView() + scrollView.addSubview(channelsStackView) + scrollView.showsHorizontalScrollIndicator = false + scrollView.clipsToBounds = false + channelsStackView.pinEdges() + scrollView.heightAnchor.constraint(equalTo: channelsStackView.heightAnchor).isActive = true + + let stackView = UIStackView(axis: .vertical, spacing: 8, [titleView, scrollView]) + addSubview(stackView) + stackView.pinEdges(insets: UIEdgeInsets(top: 16, left: 16, bottom: 8, right: 16)) + + titleView.titleLabel.text = Strings.title + titleView.detailsTextView.attributedText = { + guard let details = try? NSMutableAttributedString(markdown: Strings.details) else { + return nil + } + details.addAttributes([ + .font: UIFont.preferredFont(forTextStyle: .subheadline), + .foregroundColor: UIColor.secondaryLabel, + ], range: NSRange(location: 0, length: details.length)) + return details + }() + titleView.detailsTextView.delegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(channels: [ReaderDiscoverChannel]) { + for view in channelViews { + view.removeFromSuperview() + } + channelViews = channels.map(makeChannelView) + for view in channelViews { + channelsStackView.addArrangedSubview(view) + } + } + + func setSelectedChannel(_ channel: ReaderDiscoverChannel) { + selectedChannel = channel + refreshChannelViews() + } + + private func makeChannelView(_ channel: ReaderDiscoverChannel) -> ReaderDiscoverChannelView { + let view = ReaderDiscoverChannelView(channel: channel) + view.button.addAction(UIAction { [weak self] _ in + self?.didSelectChannel(channel) + }, for: .primaryActionTriggered) + return view + } + + private func didSelectChannel(_ channel: ReaderDiscoverChannel) { + guard selectedChannel != channel else { + return + } + selectedChannel = channel + delegate?.readerDiscoverHeaderView(self, didChangeSelection: channel) + refreshChannelViews() + } + + private func refreshChannelViews() { + for view in channelViews { + view.isSelected = view.channel == selectedChannel + } + } + + // MARK: UITextViewDelegate + + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange) -> Bool { + WPAnalytics.track(.readerDiscoverEditInterestsTapped) + + let tagsVC = ReaderTagsTableViewController(style: .plain) + tagsVC.title = Strings.editInterests + tagsVC.navigationItem.rightBarButtonItem = UIBarButtonItem(title: SharedStrings.Button.done, primaryAction: UIAction { [weak tagsVC] _ in + tagsVC?.presentingViewController?.dismiss(animated: true) + }) + let navVC = UINavigationController(rootViewController: tagsVC) + UIViewController.topViewController?.present(navVC, animated: true) + return false + } +} + +private final class ReaderDiscoverChannelView: UIView { + private let textLabel = UILabel() + private let backgroundView = UIView() + let button = UIButton(type: .system) + let channel: ReaderDiscoverChannel + + var isSelected: Bool = false { + didSet { + guard oldValue != isSelected else { return } + configure(isSelected: isSelected) + } + } + + init(channel: ReaderDiscoverChannel) { + self.channel = channel + + super.init(frame: .zero) + + textLabel.font = UIFont.preferredFont(forTextStyle: .subheadline).withWeight(.medium) + textLabel.text = channel.localizedTitle + + backgroundView.clipsToBounds = true + + addSubview(backgroundView) + addSubview(textLabel) + addSubview(button) + + textLabel.pinEdges(to: backgroundView, insets: UIEdgeInsets(horizontal: 10, vertical: 6)) + backgroundView.pinEdges(insets: UIEdgeInsets(.vertical, 8)) + button.pinEdges() + + configure(isSelected: isSelected) + } + + private func configure(isSelected: Bool) { + if isSelected { + backgroundView.backgroundColor = UIColor.label + textLabel.textColor = UIColor.systemBackground + } else { + backgroundView.backgroundColor = UIColor.opaqueSeparator.withAlphaComponent(0.33) + textLabel.textColor = UIColor.label + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + backgroundView.layer.cornerRadius = backgroundView.bounds.height / 2 + } +} + +enum ReaderDiscoverChannel: Hashable { + /// The default channel showing your selected tags. + case recommended + + /// First posts in the selected tags. + case firstPosts + + /// Latest post from your selected tags. + case latest + + case dailyPrompts + + /// A quick access for your tags. + case tag(ReaderTagTopic) + + var localizedTitle: String { + switch self { + case .recommended: + NSLocalizedString("reader.discover.channel.recommended", value: "Recommended", comment: "Header view channel (filter)") + case .firstPosts: + NSLocalizedString("reader.discover.channel.firstPost", value: "First Posts", comment: "Header view channel (filter)") + case .latest: + NSLocalizedString("reader.discover.channel.latest", value: "Latest", comment: "Header view channel (filter)") + case .dailyPrompts: + NSLocalizedString("reader.discover.channel.dailyPrompts", value: "Daily Prompts", comment: "Header view channel (filter)") + case .tag(let tag): + tag.title.localizedCapitalized + } + } + + var analyticsProperties: [String: String] { + var properties = ["channel": analyticsID] + if case let .tag(tag) = self { + properties["tag"] = tag.slug + } + return properties + } + + private var analyticsID: String { + switch self { + case .recommended: "recommended" + case .firstPosts: "first_posts" + case .latest: "latest" + case .dailyPrompts: "daily_prompts" + case .tag: "tag" + } + } +} + +private enum Strings { + static let title = NSLocalizedString("reader.discover.header.title", value: "Discover", comment: "Header view title") + static let details = NSLocalizedString("reader.discover.header.title", value: "Explore popular blogs that inspire, educate, and entertain based on your [interests](/interests).", comment: "Reader Discover header view details label. The text has a Markdown URL: [interests](/interests). Only the text in the square brackets needs to be translated: [](/interests).") + static let editInterests = NSLocalizedString("reader.editInterests.title", value: "Edit Interests", comment: "Screen title") +} + +@available(iOS 17, *) +#Preview { + ReaderDiscoverHeaderView() +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderCardsStreamViewController.swift b/WordPress/Classes/ViewRelated/Reader/ReaderDiscoverViewController.swift similarity index 64% rename from WordPress/Classes/ViewRelated/Reader/ReaderCardsStreamViewController.swift rename to WordPress/Classes/ViewRelated/Reader/ReaderDiscoverViewController.swift index c1329c91f427..c94d59b753ab 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderCardsStreamViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderDiscoverViewController.swift @@ -1,6 +1,133 @@ import Foundation +import UIKit +import Combine +import WordPressKit +import WordPressShared + +class ReaderDiscoverViewController: UIViewController, ReaderDiscoverHeaderViewDelegate, ReaderContentViewController { + private let headerView = ReaderDiscoverHeaderView() + private var selectedChannel: ReaderDiscoverChannel = .recommended + private let topic: ReaderAbstractTopic + private var streamVC: ReaderStreamViewController? + private let tags: ManagedObjectsObserver + private let viewContext: NSManagedObjectContext + private var cancellables: [AnyCancellable] = [] + + init(topic: ReaderAbstractTopic) { + wpAssert(ReaderHelpers.topicIsDiscover(topic)) + self.viewContext = ContextManager.shared.mainContext + self.topic = topic + self.tags = ManagedObjectsObserver( + predicate: ReaderSidebarTagsSection.predicate, + sortDescriptors: [SortDescriptor(\.title, order: .forward)], + context: viewContext + ) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setupNavigation() + setupHeaderView() + + configureStream(for: selectedChannel) + } + + private func setupNavigation() { + navigationItem.largeTitleDisplayMode = .never + } + + private func setupHeaderView() { + tags.$objects.sink { [weak self] tags in + self?.configureHeader(tags: tags) + }.store(in: &cancellables) + + headerView.delegate = self + } + + private func configureHeader(tags: [ReaderTagTopic]) { + let channels = tags + .filter { $0.slug != ReaderTagTopic.dailyPromptTag } + .map(ReaderDiscoverChannel.tag) + + headerView.configure(channels: [.recommended, .firstPosts, .latest, .dailyPrompts] + channels) + headerView.setSelectedChannel(selectedChannel) + } + + // MARK: - Selected Stream + + private func configureStream(for channel: ReaderDiscoverChannel) { + showStreamViewController(makeViewController(for: channel)) + } + + private func makeViewController(for channel: ReaderDiscoverChannel) -> ReaderStreamViewController { + switch channel { + case .recommended: + ReaderDiscoverStreamViewController(topic: topic) + case .firstPosts: + ReaderDiscoverStreamViewController(topic: topic, stream: .firstPosts, sorting: .date) + case .latest: + ReaderDiscoverStreamViewController(topic: topic, sorting: .date) + case .dailyPrompts: + ReaderStreamViewController.controllerWithTagSlug(ReaderTagTopic.dailyPromptTag) + case .tag(let tag): + ReaderStreamViewController.controllerWithTopic(tag) + } + } -class ReaderCardsStreamViewController: ReaderStreamViewController { + private func showStreamViewController(_ streamVC: ReaderStreamViewController) { + if let currentVC = self.streamVC { + deleteCachedReaderCards() + + currentVC.willMove(toParent: nil) + currentVC.view.removeFromSuperview() + currentVC.removeFromParent() + } + + self.streamVC = streamVC + + if FeatureFlag.readerReset.enabled { + // Important to set before `viewDidLoad` + streamVC.isReaderResetDiscoverEnabled = true + streamVC.setHeaderView(headerView) + } + + addChild(streamVC) + view.addSubview(streamVC.view) + streamVC.view.pinEdges() + streamVC.didMove(toParent: self) + } + + /// TODO: (tech-debt) the app currently stores the responses from the `/discover` + /// entpoint (cards) in Core Data with no way to distinguish between the + /// requests with different parameters like different sort. In order to + /// address it, the app currently drops the previously cached responses + /// when you change the streams. + private func deleteCachedReaderCards() { + ReaderCardService.removeAllCards() + } + + // MARK: - ReaderContentViewController (Deprecated) + + func setContent(_ content: ReaderContent) { + streamVC?.setContent(content) + } + + // MARK: - ReaderDiscoverHeaderViewDelegate + + func readerDiscoverHeaderView(_ view: ReaderDiscoverHeaderView, didChangeSelection selection: ReaderDiscoverChannel) { + self.selectedChannel = selection + configureStream(for: selection) + WPAnalytics.track(.readerDiscoverChannelSelected, properties: selection.analyticsProperties) + } +} + +private class ReaderDiscoverStreamViewController: ReaderStreamViewController { private let readerCardTopicsIdentifier = "ReaderTopicsCell" private let readerCardSitesIdentifier = "ReaderSitesCell" @@ -14,18 +141,20 @@ class ReaderCardsStreamViewController: ReaderStreamViewController { content.content as? [ReaderCard] } - lazy var cardsService: ReaderCardService = { - return ReaderCardService() - }() + private let cardsService: ReaderCardService /// Whether the current view controller is visible private var isVisible: Bool { return isViewLoaded && view.window != nil } - init() { + init(topic: ReaderAbstractTopic, stream: ReaderStream = .discover, sorting: ReaderSortingOption = .noSorting) { + self.cardsService = ReaderCardService(stream: stream, sorting: sorting) + super.init(nibName: nil, bundle: nil) + self.readerTopic = topic + // register table view cells specific to this controller as early as possible. // the superclass might trigger `layoutIfNeeded` from its `viewDidLoad`, and we want to make sure that // all the cell types have been registered by that time. @@ -40,6 +169,7 @@ class ReaderCardsStreamViewController: ReaderStreamViewController { override func viewDidLoad() { super.viewDidLoad() + addObservers() } @@ -48,7 +178,7 @@ class ReaderCardsStreamViewController: ReaderStreamViewController { displaySelectInterestsIfNeeded() } - // MARK: - TableView Related + // MARK: - UITableView override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let card = cards?[indexPath.row] else { @@ -185,20 +315,6 @@ class ReaderCardsStreamViewController: ReaderStreamViewController { return NSPredicate(format: "post != NULL OR topics.@count != 0 OR sites.@count != 0") } - /// Convenience method for instantiating an instance of ReaderCardsStreamViewController - /// for a existing topic. - /// - /// - Parameters: - /// - topic: Any subclass of ReaderAbstractTopic - /// - /// - Returns: An instance of the controller - /// - class func controller(topic: ReaderAbstractTopic) -> ReaderCardsStreamViewController { - let controller = ReaderCardsStreamViewController() - controller.readerTopic = topic - return controller - } - private func addObservers() { // Listens for when the reader manage view controller is dismissed @@ -236,7 +352,7 @@ class ReaderCardsStreamViewController: ReaderStreamViewController { } // MARK: - Select Interests Display -private extension ReaderCardsStreamViewController { +private extension ReaderDiscoverStreamViewController { func displaySelectInterestsIfNeeded() { selectInterestsViewController.userIsFollowingTopics { [weak self] isFollowing in guard let self else { @@ -253,7 +369,7 @@ private extension ReaderCardsStreamViewController { // MARK: - ReaderTopicsTableCardCellDelegate -extension ReaderCardsStreamViewController: ReaderTopicsTableCardCellDelegate { +extension ReaderDiscoverStreamViewController: ReaderTopicsTableCardCellDelegate { func didSelect(topic: ReaderAbstractTopic) { if topic as? ReaderTagTopic != nil { WPAnalytics.trackReader(.readerDiscoverTopicTapped) @@ -273,7 +389,7 @@ extension ReaderCardsStreamViewController: ReaderTopicsTableCardCellDelegate { // MARK: - ReaderSitesCardCellDelegate -extension ReaderCardsStreamViewController: ReaderSitesCardCellDelegate { +extension ReaderDiscoverStreamViewController: ReaderSitesCardCellDelegate { func handleFollowActionForTopic(_ topic: ReaderAbstractTopic, for cell: ReaderSitesCardCell) { toggleFollowingForTopic(topic) { success in cell.didToggleFollowing(topic, with: success) diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderPostCell.swift b/WordPress/Classes/ViewRelated/Reader/ReaderPostCell.swift index 20f42ad93882..c967bcfc77a7 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderPostCell.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderPostCell.swift @@ -61,7 +61,7 @@ final class ReaderPostCell: UITableViewCell { } private func updateSeparatorsInsets() { - separatorInset = UIEdgeInsets(.leading, isSeparatorHidden ? 9999 : view.insets.left + contentView.readableContentGuide.layoutFrame.minX) + separatorInset = UIEdgeInsets(.leading, isSeparatorHidden ? 9999 : view.insets.left + (isCompact ? 0 : contentView.readableContentGuide.layoutFrame.minX)) } override func updateConstraints() { diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderStreamTitleView.swift b/WordPress/Classes/ViewRelated/Reader/ReaderStreamTitleView.swift new file mode 100644 index 000000000000..8764f24665af --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderStreamTitleView.swift @@ -0,0 +1,24 @@ +import UIKit +import WordPressUI + +/// A Reader stream header with a large title and a description. +final class ReaderStreamTitleView: UIView { + let titleLabel = UILabel() + let detailsTextView = UITextView.makeLabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + titleLabel.font = UIFont.preferredFont(forTextStyle: .largeTitle).withWeight(.bold) + detailsTextView.font = UIFont.preferredFont(forTextStyle: .subheadline) + detailsTextView.textColor = .secondaryLabel + + let stackView = UIStackView(axis: .vertical, alignment: .leading, [titleLabel, detailsTextView]) + addSubview(stackView) + stackView.pinEdges() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Ghost.swift b/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Ghost.swift index 2c5e659a155d..7a66c783a796 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Ghost.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Ghost.swift @@ -11,12 +11,22 @@ extension ReaderStreamViewController { ghostableTableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(ghostableTableView) - NSLayoutConstraint.activate([ - ghostableTableView.widthAnchor.constraint(equalTo: tableView.widthAnchor, multiplier: 1), - ghostableTableView.heightAnchor.constraint(equalTo: tableView.heightAnchor, multiplier: 1), - ghostableTableView.centerXAnchor.constraint(equalTo: tableView.centerXAnchor), - ghostableTableView.centerYAnchor.constraint(equalTo: tableView.centerYAnchor) - ]) + if isReaderResetDiscoverEnabled { + NSLayoutConstraint.activate([ + ghostableTableView.topAnchor.constraint(equalTo: tableView.tableHeaderView?.bottomAnchor ?? view.topAnchor), + ghostableTableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + ghostableTableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ghostableTableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } else { + view.addSubview(ghostableTableView) + NSLayoutConstraint.activate([ + ghostableTableView.widthAnchor.constraint(equalTo: tableView.widthAnchor, multiplier: 1), + ghostableTableView.heightAnchor.constraint(equalTo: tableView.heightAnchor, multiplier: 1), + ghostableTableView.centerXAnchor.constraint(equalTo: tableView.centerXAnchor), + ghostableTableView.centerYAnchor.constraint(equalTo: tableView.centerYAnchor) + ]) + } ghostableTableView.accessibilityIdentifier = "Reader Ghost Loading" ghostableTableView.cellLayoutMarginsFollowReadableWidth = true diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Helper.swift b/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Helper.swift index e76c03cb003b..d371fef73fde 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Helper.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Helper.swift @@ -10,15 +10,6 @@ extension ReaderStreamViewController { var message: String } - /// Returns the ReaderStreamHeader appropriate for a particular ReaderTopic. - /// The header is returned already configured - /// - /// - Parameter topic: A ReaderTopic - /// - Parameter isLoggedIn: A boolean flag indicating if the user is logged in - /// - Parameter delegate: The header delegate - /// - /// - Returns: A configured instance of UIView. - /// func headerForStream(_ topic: ReaderAbstractTopic?, isLoggedIn: Bool, container: UITableViewController) -> UIView? { if let topic, let header = headerForStream(topic) { @@ -35,7 +26,6 @@ extension ReaderStreamViewController { } func headerForStream(_ topic: ReaderAbstractTopic) -> ReaderHeader? { - if ReaderHelpers.isTopicTag(topic) && !isContentFiltered { guard let nibViews = Bundle.main.loadNibNamed("ReaderTagStreamHeader", owner: nil, options: nil) as? [ReaderTagStreamHeader] else { return nil diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController.swift b/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController.swift index 72c0b6221722..08379eddf285 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController.swift @@ -260,6 +260,8 @@ import AutomatticTracks lazy var isSidebarModeEnabled = splitViewController?.isCollapsed == false + var isReaderResetDiscoverEnabled = false + // MARK: - Factory Methods /// Convenience method for instantiating an instance of ReaderStreamViewController @@ -540,11 +542,17 @@ import AutomatticTracks // MARK: - Configuration / Topic Presentation @objc private func configureStreamHeader() { + guard !isReaderResetDiscoverEnabled else { + return + } guard let headerView = headerForStream(readerTopic, isLoggedIn: isLoggedIn, container: tableViewController) else { tableView.tableHeaderView = nil return } + setHeaderView(headerView) + } + func setHeaderView(_ headerView: UIView) { if let tableHeaderView = tableView.tableHeaderView { headerView.isHidden = tableHeaderView.isHidden } @@ -1715,8 +1723,9 @@ extension ReaderStreamViewController { if content.contentCount > 0 { return } - - tableView.tableHeaderView?.isHidden = true + if !isReaderResetDiscoverEnabled { + tableView.tableHeaderView?.isHidden = true + } configureResultsStatus(title: ResultsStatusText.fetchingPostsTitle, accessoryView: NoResultsViewController.loadingAccessoryView()) displayResultsStatus() showGhost() @@ -1736,7 +1745,9 @@ extension ReaderStreamViewController { return } - tableView.tableHeaderView?.isHidden = true + if !isReaderResetDiscoverEnabled { + tableView.tableHeaderView?.isHidden = true + } guard connectionAvailable() else { displayNoConnectionView() diff --git a/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabViewController.swift b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabViewController.swift index 2b88f2f4105d..43c9ede06538 100644 --- a/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabViewController.swift @@ -22,7 +22,7 @@ class ReaderTabViewController: UIViewController { title = ReaderTabConstants.title - ReaderCardService().clean() + ReaderCardService.removeAllCards() viewModel.filterTapped = { [weak self] (filter, fromView, completion) in guard let self = self else { diff --git a/WordPress/Classes/ViewRelated/Reader/Tab Navigation/WPTabBarController+ReaderTabNavigation.swift b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/WPTabBarController+ReaderTabNavigation.swift index 72ca7c6bf8cc..a91fbab243a2 100644 --- a/WordPress/Classes/ViewRelated/Reader/Tab Navigation/WPTabBarController+ReaderTabNavigation.swift +++ b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/WPTabBarController+ReaderTabNavigation.swift @@ -18,7 +18,7 @@ extension WPTabBarController { let viewModel = ReaderTabViewModel( readerContentFactory: { content in if content.topicType == .discover, let topic = content.topic { - return ReaderCardsStreamViewController.controller(topic: topic) + return ReaderDiscoverViewController(topic: topic) } else if let topic = content.topic { return ReaderStreamViewController.controllerWithTopic(topic) } else { diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index 45781644222c..c98d0923b042 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -433,6 +433,10 @@ 0C0AD10B2B0CCFA400EC06E6 /* MediaPreviewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0AD1092B0CCFA400EC06E6 /* MediaPreviewController.swift */; }; 0C0AE7592A8FAD6A007D9D6C /* MediaPickerMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0AE7582A8FAD6A007D9D6C /* MediaPickerMenu.swift */; }; 0C0AE75A2A8FAD6A007D9D6C /* MediaPickerMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0AE7582A8FAD6A007D9D6C /* MediaPickerMenu.swift */; }; + 0C0BEEEB2CC02D2C0073F4E0 /* ReaderDiscoverHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0BEEEA2CC02D2C0073F4E0 /* ReaderDiscoverHeaderView.swift */; }; + 0C0BEEEC2CC02D2C0073F4E0 /* ReaderDiscoverHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0BEEEA2CC02D2C0073F4E0 /* ReaderDiscoverHeaderView.swift */; }; + 0C0BEEEE2CC169A60073F4E0 /* ReaderStreamTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0BEEED2CC169A40073F4E0 /* ReaderStreamTitleView.swift */; }; + 0C0BEEEF2CC169A60073F4E0 /* ReaderStreamTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0BEEED2CC169A40073F4E0 /* ReaderStreamTitleView.swift */; }; 0C0D3B0D2A4C79DE0050A00D /* BlazeCampaignsStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D3B0C2A4C79DE0050A00D /* BlazeCampaignsStream.swift */; }; 0C0D3B0E2A4C79DE0050A00D /* BlazeCampaignsStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D3B0C2A4C79DE0050A00D /* BlazeCampaignsStream.swift */; }; 0C0DF8942C2DF14600011B7D /* LoginFacadeTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0C0DF8932C2DF12A00011B7D /* LoginFacadeTests.m */; }; @@ -2594,7 +2598,7 @@ 8BB185CC24B6058600A4CCE8 /* reader-cards.json in Resources */ = {isa = PBXBuildFile; fileRef = 8BB185CB24B6058600A4CCE8 /* reader-cards.json */; }; 8BB185CE24B62CE100A4CCE8 /* ReaderCardServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB185CD24B62CE100A4CCE8 /* ReaderCardServiceTests.swift */; }; 8BB185CF24B62D7600A4CCE8 /* reader-cards.json in Resources */ = {isa = PBXBuildFile; fileRef = 8BB185CB24B6058600A4CCE8 /* reader-cards.json */; }; - 8BB185D224B63D5F00A4CCE8 /* ReaderCardsStreamViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB185D024B63D1600A4CCE8 /* ReaderCardsStreamViewController.swift */; }; + 8BB185D224B63D5F00A4CCE8 /* ReaderDiscoverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB185D024B63D1600A4CCE8 /* ReaderDiscoverViewController.swift */; }; 8BB185D524B66FE600A4CCE8 /* ReaderCard+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB185D324B66FE500A4CCE8 /* ReaderCard+CoreDataProperties.swift */; }; 8BB185D624B66FE600A4CCE8 /* ReaderCard+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB185D424B66FE600A4CCE8 /* ReaderCard+CoreDataClass.swift */; }; 8BBBCE702717651200B277AC /* JetpackModuleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBBCE6F2717651200B277AC /* JetpackModuleHelper.swift */; }; @@ -4929,7 +4933,7 @@ FABB23572602FC2C00C8785C /* PostTagPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E155EC711E9B7DCE009D7F63 /* PostTagPickerViewController.swift */; }; FABB23582602FC2C00C8785C /* SignupEpilogueCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98A25BD0203CB25F006A5807 /* SignupEpilogueCell.swift */; }; FABB23592602FC2C00C8785C /* NoteBlockActionsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57AF5F91ACDC73D0075A7D2 /* NoteBlockActionsTableViewCell.swift */; }; - FABB235A2602FC2C00C8785C /* ReaderCardsStreamViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB185D024B63D1600A4CCE8 /* ReaderCardsStreamViewController.swift */; }; + FABB235A2602FC2C00C8785C /* ReaderDiscoverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB185D024B63D1600A4CCE8 /* ReaderDiscoverViewController.swift */; }; FABB235B2602FC2C00C8785C /* DeleteSiteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742C79971E5F511C00DB1608 /* DeleteSiteViewController.swift */; }; FABB235C2602FC2C00C8785C /* WPAnalyticsTrackerWPCom.m in Sources */ = {isa = PBXBuildFile; fileRef = 85DA8C4318F3F29A0074C8A4 /* WPAnalyticsTrackerWPCom.m */; }; FABB235D2602FC2C00C8785C /* AztecAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50C0C5C1EF42A4A00372C65 /* AztecAttachmentViewController.swift */; }; @@ -6441,6 +6445,8 @@ 0C0AD1052B0C483F00EC06E6 /* ExternalMediaSelectionTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalMediaSelectionTitleView.swift; sourceTree = ""; }; 0C0AD1092B0CCFA400EC06E6 /* MediaPreviewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewController.swift; sourceTree = ""; }; 0C0AE7582A8FAD6A007D9D6C /* MediaPickerMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPickerMenu.swift; sourceTree = ""; }; + 0C0BEEEA2CC02D2C0073F4E0 /* ReaderDiscoverHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderDiscoverHeaderView.swift; sourceTree = ""; }; + 0C0BEEED2CC169A40073F4E0 /* ReaderStreamTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderStreamTitleView.swift; sourceTree = ""; }; 0C0D3B0C2A4C79DE0050A00D /* BlazeCampaignsStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeCampaignsStream.swift; sourceTree = ""; }; 0C0DF8932C2DF12A00011B7D /* LoginFacadeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LoginFacadeTests.m; sourceTree = ""; }; 0C0F05522C6290670040390D /* ReaderFeedCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderFeedCell.swift; sourceTree = ""; }; @@ -8134,7 +8140,7 @@ 8BB185C524B5FB8500A4CCE8 /* ReaderCardService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCardService.swift; sourceTree = ""; }; 8BB185CB24B6058600A4CCE8 /* reader-cards.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "reader-cards.json"; sourceTree = ""; }; 8BB185CD24B62CE100A4CCE8 /* ReaderCardServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCardServiceTests.swift; sourceTree = ""; }; - 8BB185D024B63D1600A4CCE8 /* ReaderCardsStreamViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCardsStreamViewController.swift; sourceTree = ""; }; + 8BB185D024B63D1600A4CCE8 /* ReaderDiscoverViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderDiscoverViewController.swift; sourceTree = ""; }; 8BB185D324B66FE500A4CCE8 /* ReaderCard+CoreDataProperties.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReaderCard+CoreDataProperties.swift"; sourceTree = ""; }; 8BB185D424B66FE600A4CCE8 /* ReaderCard+CoreDataClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReaderCard+CoreDataClass.swift"; sourceTree = ""; }; 8BBBCE6F2717651200B277AC /* JetpackModuleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackModuleHelper.swift; sourceTree = ""; }; @@ -13346,7 +13352,7 @@ 0C0F05522C6290670040390D /* ReaderFeedCell.swift */, 5D1D04741B7A50B100CDE646 /* ReaderStreamViewController.swift */, 8BCB83D024C21063001581BD /* ReaderStreamViewController+Ghost.swift */, - 8BB185D024B63D1600A4CCE8 /* ReaderCardsStreamViewController.swift */, + 8BB185D024B63D1600A4CCE8 /* ReaderDiscoverViewController.swift */, 3234BB162530DFCA0068DA40 /* ReaderTableCardCell.swift */, 8BCF957924C6044000712056 /* ReaderTopicsCardCell.swift */, 3234B8E6252FA0930068DA40 /* ReaderSitesCardCell.swift */, @@ -17882,12 +17888,14 @@ E6D2E16A1B8B41AC0000ED14 /* Headers */ = { isa = PBXGroup; children = ( + 0C0BEEED2CC169A40073F4E0 /* ReaderStreamTitleView.swift */, E6D2E1681B8AAD9B0000ED14 /* ReaderListStreamHeader.swift */, E6D2E1621B8AAA340000ED14 /* ReaderListStreamHeader.xib */, 83A337A02A9FA525009ED60C /* ReaderSiteHeaderView.swift */, E6D2E1641B8AAD7E0000ED14 /* ReaderSiteStreamHeader.swift */, E6D2E15E1B8A9C830000ED14 /* ReaderSiteStreamHeader.xib */, E6D2E16B1B8B423B0000ED14 /* ReaderStreamHeader.swift */, + 0C0BEEEA2CC02D2C0073F4E0 /* ReaderDiscoverHeaderView.swift */, E6D2E1661B8AAD8C0000ED14 /* ReaderTagStreamHeader.swift */, E6D2E1601B8AA4410000ED14 /* ReaderTagStreamHeader.xib */, ); @@ -22177,6 +22185,7 @@ 17C1D7DC26735631006C8970 /* EmojiRenderer.swift in Sources */, C81CCD7B243BF7A600A83E27 /* TenorStrings.swift in Sources */, 91DCE84621A6A7F50062F134 /* PostEditor+MoreOptions.swift in Sources */, + 0C0BEEEC2CC02D2C0073F4E0 /* ReaderDiscoverHeaderView.swift in Sources */, 3FB1929526C79EC6000F5AA3 /* Date+Formats.swift in Sources */, FA4EBCC12A98F00200BA3DFB /* NoSitesView.swift in Sources */, 0C7762232AAFD39700E07A88 /* SiteMediaAddMediaMenuController.swift in Sources */, @@ -22361,6 +22370,7 @@ FF70A3231FD5840500BC270D /* UIImage+Export.swift in Sources */, D81322B32050F9110067714D /* NotificationName+Names.swift in Sources */, F4BECD1B288EE5220078391A /* SuggestionsViewModelType.swift in Sources */, + 0C0BEEEE2CC169A60073F4E0 /* ReaderStreamTitleView.swift in Sources */, B5DD04741CD3DAB00003DF89 /* NSFetchedResultsController+Helpers.swift in Sources */, B55F1AA81C10936600FD04D4 /* BlogSettings+Discussion.swift in Sources */, E155EC721E9B7DCE009D7F63 /* PostTagPickerViewController.swift in Sources */, @@ -22369,7 +22379,7 @@ B57AF5FA1ACDC73D0075A7D2 /* NoteBlockActionsTableViewCell.swift in Sources */, 8BA125EB27D8F5E4008B779F /* UIView+PinSubviewPriority.swift in Sources */, FEAC916E28001FC4005026E7 /* AvatarTrainView.swift in Sources */, - 8BB185D224B63D5F00A4CCE8 /* ReaderCardsStreamViewController.swift in Sources */, + 8BB185D224B63D5F00A4CCE8 /* ReaderDiscoverViewController.swift in Sources */, 742C79981E5F511C00DB1608 /* DeleteSiteViewController.swift in Sources */, 0C748B4B2A9D71A100809E1A /* SiteMediaCollectionViewController.swift in Sources */, 85DA8C4418F3F29A0074C8A4 /* WPAnalyticsTrackerWPCom.m in Sources */, @@ -24496,6 +24506,7 @@ 0C1C083F2B9BF9A000E52F8C /* PostRepository+Helpers.swift in Sources */, FABB21082602FC2C00C8785C /* SiteCreationHeaderData.swift in Sources */, FABB21092602FC2C00C8785C /* WPAuthTokenIssueSolver.m in Sources */, + 0C0BEEEB2CC02D2C0073F4E0 /* ReaderDiscoverHeaderView.swift in Sources */, FABB210B2602FC2C00C8785C /* ReaderDetailViewController.swift in Sources */, FABB210C2602FC2C00C8785C /* HomeWidgetThisWeekData.swift in Sources */, FABB210D2602FC2C00C8785C /* PersonHeaderCell.swift in Sources */, @@ -25323,7 +25334,7 @@ FABB23572602FC2C00C8785C /* PostTagPickerViewController.swift in Sources */, FABB23582602FC2C00C8785C /* SignupEpilogueCell.swift in Sources */, FABB23592602FC2C00C8785C /* NoteBlockActionsTableViewCell.swift in Sources */, - FABB235A2602FC2C00C8785C /* ReaderCardsStreamViewController.swift in Sources */, + FABB235A2602FC2C00C8785C /* ReaderDiscoverViewController.swift in Sources */, 0C14F9722C8A48BA0084E5C0 /* ReaderSubscriptionCell.swift in Sources */, FABB235B2602FC2C00C8785C /* DeleteSiteViewController.swift in Sources */, FABB235C2602FC2C00C8785C /* WPAnalyticsTrackerWPCom.m in Sources */, @@ -26074,6 +26085,7 @@ FABB25772602FC2C00C8785C /* NotificationMediaDownloader.swift in Sources */, FABB25782602FC2C00C8785C /* WizardDelegate.swift in Sources */, 0CDDCA092C8F4126005AACA3 /* ReaderTagsAddTagView.swift in Sources */, + 0C0BEEEF2CC169A60073F4E0 /* ReaderStreamTitleView.swift in Sources */, FABB25792602FC2C00C8785C /* Blog+Files.swift in Sources */, FABB257A2602FC2C00C8785C /* RevisionDiffsBrowserViewController.swift in Sources */, FA4FE0B42BEB6EF700A635D3 /* PostHelper+Metadata.swift in Sources */, diff --git a/WordPress/WordPressTest/ReaderCardServiceTests.swift b/WordPress/WordPressTest/ReaderCardServiceTests.swift index 3ee06e9df02f..4ea5f9dadc34 100644 --- a/WordPress/WordPressTest/ReaderCardServiceTests.swift +++ b/WordPress/WordPressTest/ReaderCardServiceTests.swift @@ -102,10 +102,10 @@ class ReaderCardServiceTests: CoreDataTestCase { } final class ReaderPostServiceRemoteMock: ReaderCardServiceRemote { - var shouldCallFailure = false - func fetchStreamCards(for topics: [String], + func fetchStreamCards(stream: WordPressKit.ReaderStream, + for topics: [String], page: String?, sortingOption: WordPressKit.ReaderSortingOption, refreshCount: Int?, @@ -115,15 +115,6 @@ final class ReaderPostServiceRemoteMock: ReaderCardServiceRemote { mockFetch(success: success, failure: failure) } - func fetchCards(for topics: [String], - page: String?, - sortingOption: WordPressKit.ReaderSortingOption, - refreshCount: Int?, - success: @escaping ([WordPressKit.RemoteReaderCard], String?) -> Void, - failure: @escaping (any Error) -> Void) { - mockFetch(success: success, failure: failure) - } - func mockFetch(success: @escaping ([WordPressKit.RemoteReaderCard], String?) -> Void, failure: @escaping (any Error) -> Void) { guard !shouldCallFailure else { diff --git a/WordPress/WordPressTest/ReaderTabViewModelTests.swift b/WordPress/WordPressTest/ReaderTabViewModelTests.swift index 84dd29289f22..39eafec9670e 100644 --- a/WordPress/WordPressTest/ReaderTabViewModelTests.swift +++ b/WordPress/WordPressTest/ReaderTabViewModelTests.swift @@ -67,7 +67,7 @@ class ReaderTabViewModelTests: CoreDataTestCase { viewModel.filterTapped = { filter, view, completion in filterTappedExpectation.fulfill() } - let viewController = UIViewController() + // When viewModel.didTapStreamFilterButton(with: filter) // Then