Skip to content

Reader: Update the main tabs #24441

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
Apr 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import UIKit
import WordPressUI

final class ReaderDiscoverTabViewController: ReaderDiscoverViewController {
override func viewDidLoad() {
super.viewDidLoad()

navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(named: "reader-menu-search"), style: .plain, target: self, action: #selector(buttonSearchTapped))
}

@objc private func buttonSearchTapped() {
let searchVC = ReaderSearchViewController()
searchVC.isStandaloneAppModeEnabled = true
searchVC.navigationItem.largeTitleDisplayMode = .always
navigationController?.pushViewController(searchVC, animated: true)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import UIKit
import SwiftUI
import Combine
import WordPressUI

struct ReaderFollowingListsView: View {
let viewModel: ReaderFollowingViewModel

@FetchRequest(
sortDescriptors: [SortDescriptor(\.title, order: .forward)]
)
private var lists: FetchedResults<ReaderListTopic>

var body: some View {
if lists.isEmpty {
EmptyStateView(Strings.emptyTitle, systemImage: "list.clipboard", description: Strings.emptyDetails)
.frame(height: 420)
.listRowSeparator(.hidden)
} else {
items
}
}

private var items: some View {
ForEach(lists, id: \.self) { list in
Button {
viewModel.navigate(to: .topic(list))
} label: {
Label {
Text(list.title)
.lineLimit(1)
} icon: {
ReaderSidebarImage(name: "reader-menu-list")
.foregroundStyle(.secondary)
}
}
}
}
}

private enum Strings {
static let emptyTitle = NSLocalizedString("reader.following.lists.emptyTitle", value: "Lists Empty", comment: "Empty state view")
static let emptyDetails = NSLocalizedString("reader.following.lists.emptyTitle", value: "Create lists to following different topics in one convenient feed", comment: "Empty state view")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import SwiftUI
import WordPressUI

/// A "Subscriptions" tab content view for on Reader's "Following" screen.
struct ReaderFollowingSubscriptionsView: View {
let viewModel: ReaderFollowingViewModel

@FetchRequest(
sortDescriptors: [SortDescriptor(\.title, order: .forward)],
predicate: NSPredicate(format: "following = YES")
)
private var subscriptions: FetchedResults<ReaderSiteTopic>

var body: some View {
ForEach(subscriptions, id: \.objectID, content: makeSubscriptionCell)
.onDelete(perform: delete)
}

private func makeSubscriptionCell(for site: ReaderSiteTopic) -> some View {
Button {
viewModel.navigate(to: .topic(site))
} label: {
ReaderSubscriptionCell(site: site, onDelete: delete)
}
.swipeActions(edge: .leading) {
if let siteURL = URL(string: site.siteURL) {
ShareLink(item: siteURL).tint(.blue)
}
}
.swipeActions(edge: .trailing) {
Button(SharedStrings.Reader.unfollow, role: .destructive) {
ReaderSubscriptionHelper().unfollow(site)
}.tint(.red)
}
}

private func getSubscription(at index: Int) -> ReaderSiteTopic {
// if isShowingSearchResuts {
// searchResults[index]
// } else {
subscriptions[index]
// }
}

private func delete(at offsets: IndexSet) {
for site in offsets.map(getSubscription) {
delete(site)
}
}

private func delete(_ site: ReaderSiteTopic) {
ReaderSubscriptionHelper().unfollow(site)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import UIKit
import SwiftUI
import Combine
import WordPressUI

struct ReaderFollowingTagsView: View {
let viewModel: ReaderFollowingViewModel

@FetchRequest(
sortDescriptors: [SortDescriptor(\.title, order: .forward)],
predicate: ReaderSidebarTagsSection.predicate
)
private var tags: FetchedResults<ReaderTagTopic>

static let predicate = NSPredicate(format: "following == YES AND showInMenu == YES AND type == 'tag'")

var body: some View {
ForEach(tags, id: \.self) { tag in
Button {
viewModel.navigate(to: .topic(tag))
} label: {
Label {
Text(tag.title)
.lineLimit(1)
} icon: {
ReaderSidebarImage(name: "reader-menu-tag")
.foregroundStyle(.secondary)
}
}
.swipeActions(edge: .trailing) {
Button(SharedStrings.Reader.unfollow, role: .destructive) {
ReaderTagsHelper().unfollow(tag)
}.tint(.red)
}
.contextMenu(menuItems: {
Button(SharedStrings.Reader.unfollow, systemImage: "trash", role: .destructive) {
ReaderTagsHelper().unfollow(tag)
}
}, preview: {
ReaderTopicPreviewView(topic: tag)
})
}
.onDelete(perform: delete)

Button {
viewModel.navigate(to: .discoverTags)
} label: {
Label {
Text(Strings.discoverTags)
} icon: {
ReaderSidebarImage(name: "reader-menu-explorer")
}
}
.listItemTint(AppColor.primary)
}

func delete(at offsets: IndexSet) {
let tags = offsets.map { self.tags[$0] }
for tag in tags {
ReaderTagsHelper().unfollow(tag)
}
}
}

private struct Strings {
static let addTag = NSLocalizedString("reader.following.tags.addTag", value: "Add tag", comment: "Button title")
static let discoverTags = NSLocalizedString("reader.following.tags.discoverTags", value: "Discover More Tags", comment: "Button title")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import UIKit
import SwiftUI
import WordPressUI

final class ReaderFollowingViewController: UIHostingController<AnyView>, UIPopoverPresentationControllerDelegate {
private let mainContext = ContextManager.shared.mainContext
private let viewModel = ReaderFollowingViewModel()

init() {
let view = AnyView(ReaderFollowingView(viewModel: viewModel)
.environment(\.managedObjectContext, mainContext))
super.init(rootView: view)

viewModel._navigate = { [weak self] in
self?.navigate(to: $0)
}
}

required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func viewDidLoad() {
super.viewDidLoad()

title = SharedStrings.Reader.following
navigationItem.largeTitleDisplayMode = .always
navigationController?.navigationBar.prefersLargeTitles = true
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(named: "reader-menu-plus"), style: .plain, target: self, action: #selector(buttonAddTapped))
}

private func navigate(to route: ReaderFollowingNavigation) {
switch route {
case .topic(let topic):
let streamVC = ReaderStreamViewController.controllerWithTopic(topic)
navigationController?.pushViewController(streamVC, animated: true)
case .discoverTags:
ReaderSelectInterestsViewController.show(from: self)
}
}

@objc private func buttonAddTapped(_ item: UIBarButtonItem) {
switch viewModel.selectedTab {
case .subscriptions:
let hostVC = UIHostingController(rootView: ReaderSubscriptionAddView())
hostVC.modalPresentationStyle = .popover
hostVC.popoverPresentationController?.delegate = self
hostVC.popoverPresentationController?.sourceItem = item
// TODO: (reader) remove hardcoded size
hostVC.preferredContentSize = CGSize(width: 320, height: 140)
present(hostVC, animated: true)
case .lists:
let alert = UIAlertController(title: "This feature is not supported in the prototype", message: nil, preferredStyle: .alert)
alert.addCancelActionWithTitle(SharedStrings.Button.ok)
present(alert, animated: true)
case .tags:
let addTagVC = UIHostingController(rootView: ReaderTagsAddTagView())
addTagVC.modalPresentationStyle = .popover
addTagVC.popoverPresentationController?.delegate = self
addTagVC.popoverPresentationController?.sourceItem = item
// TODO: (reader) remove hardcoded size
addTagVC.preferredContentSize = CGSize(width: 320, height: 140)
present(addTagVC, animated: true, completion: nil)
}
}

// MARK: UIPopoverPresentationControllerDelegate

func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
return .none
}
}

private struct ReaderFollowingView: View {
@ObservedObject var viewModel: ReaderFollowingViewModel

var body: some View {
List {
filters

switch viewModel.selectedTab {
case .subscriptions:
ReaderFollowingSubscriptionsView(viewModel: viewModel)
case .lists:
ReaderFollowingListsView(viewModel: viewModel)
case .tags:
ReaderFollowingTagsView(viewModel: viewModel)
}
}
.listStyle(.plain)
.task { await viewModel.refresh() }
.refreshable { await viewModel.refresh() }
// TODO: (reader) add searching
// .searchable(text: $viewModel.searchText)
}

private var filters: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
ForEach(ReaderFollowingTab.allCases, id: \.self) { tab in
Button {
viewModel.selectedTab = tab
} label: {
MenuItem(tab.title, isSelected: tab == viewModel.selectedTab)
}
.buttonStyle(.plain)
}
Spacer()
}
.font(.subheadline)
Divider()
}
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.listRowSeparator(.hidden, edges: .all)
}
}

// TODO: (reader) create a proper reusable component; this one is just for a prototype
private struct MenuItem: View {
let title: String
let isSelected: Bool

init(_ title: String, isSelected: Bool = false) {
self.title = title
self.isSelected = isSelected
}

var body: some View {
VStack {
Text(title)
.fontWeight(isSelected ? .bold : .regular)
.foregroundStyle(isSelected ? Color.primary : Color.secondary)
Rectangle()
.frame(height: 2)
.foregroundStyle(isSelected ? Color.black : Color(uiColor: .separator))
.opacity(isSelected ? 1 : 0)
}
}
}
Loading