diff --git a/Frameworks/Feed/Bundle/Resources/Catalogs/Localizable.xcstrings b/Frameworks/Feed/Bundle/Resources/Catalogs/Localizable.xcstrings index 6c279f8..dc58c85 100644 --- a/Frameworks/Feed/Bundle/Resources/Catalogs/Localizable.xcstrings +++ b/Frameworks/Feed/Bundle/Resources/Catalogs/Localizable.xcstrings @@ -120,6 +120,66 @@ } } } + }, + "view.feed-list.unavailable.empty.description.text" : { + "comment" : "The text for the description of the Unavailable component in the Feed List view when no items are available", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No reviews matching the criteria have been found.\nPlease try fetching them at a later time." + } + } + } + }, + "view.feed-list.unavailable.empty.title.text" : { + "comment" : "The text for the title of the Unavailable component in the Feed List view when no items are available", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No reviews available" + } + } + } + }, + "view.feed-list.unavailable.error.button.try-again.text" : { + "comment" : "The text for the Try Again button of the Unavailable component in the Feed List view when an error occurred", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try fetching data again" + } + } + } + }, + "view.feed-list.unavailable.error.description.text" : { + "comment" : "The text for the title of the Unavailable component in the Feed List view when an error occurred", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "An error occurred while trying to fetch the reviews.\nPlease try this operation them at a later time." + } + } + } + }, + "view.feed-list.unavailable.error.title.text" : { + "comment" : "The text for the title of the Unavailable component in the Feed List view when an error occurred", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Something went wrong" + } + } + } } }, "version" : "1.0" diff --git a/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift b/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift index 4fee1d3..95128b0 100644 --- a/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift +++ b/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift @@ -13,12 +13,14 @@ import ReviewsUIKit import SwiftUI import UIKit -final class FeedListViewController: UITableViewController { - +final class FeedListViewController: UIViewController { + // MARK: Constants private let viewModel: ViewModel // MARK: Properties + private var unavailableView: FeedUnavailableView? + private var cancellables: Set = [] // MARK: Outlets @@ -26,10 +28,10 @@ final class FeedListViewController: UITableViewController { let indicator = UIActivityIndicatorView() indicator.translatesAutoresizingMaskIntoConstraints = false - + return indicator }() - + private lazy var filterButton = { UIBarButtonItem( title: NSLocalizedString( @@ -46,29 +48,27 @@ final class FeedListViewController: UITableViewController { comment: .empty ), image: UIImage.Icon.star, - children: { - FilterOption.allCases.map { option -> UIAction in + children: FilterOption.allCases.map { option -> UIAction in .init( title: option.text, image: .init(systemName: option.icon) ) { [weak self] _ in self?.viewModel.filter(by: option) } - } - }() + } ) ) }() private lazy var pullControl = { let control = UIRefreshControl() - + control.attributedTitle = .init(string: NSLocalizedString( .Key.PullToRefresh.title, bundle: .module, comment: .empty )) - + control.addTarget( self, action: #selector(refresh(_:)), @@ -77,12 +77,26 @@ final class FeedListViewController: UITableViewController { return control }() + + private lazy var tableView = { + let table = UITableView( + frame: .zero, + style: .plain + ) + table.translatesAutoresizingMaskIntoConstraints = false + + return table + }() + // MARK: Initialisers init(_ viewModel: ViewModel) { self.viewModel = viewModel - - super.init(style: .plain) + + super.init( + nibName: nil, + bundle: nil + ) } required init?(coder: NSCoder) { @@ -93,11 +107,11 @@ final class FeedListViewController: UITableViewController { var items: [Review] { viewModel.items } - + // MARK: UIViewController override func viewDidLoad() { super.viewDidLoad() - + setNavigationBar() setView() setLayout() @@ -107,15 +121,20 @@ final class FeedListViewController: UITableViewController { viewModel.fetch() } - // MARK: UITableViewDataSource - override func tableView( +} + +// MARK: - UITableViewDataSource +extension FeedListViewController: UITableViewDataSource { + + // MARK: Functions + func tableView( _ tableView: UITableView, numberOfRowsInSection section: Int ) -> Int { viewModel.itemsCount } - override func tableView( + func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { @@ -126,8 +145,13 @@ final class FeedListViewController: UITableViewController { } } - // MARK: UITableViewDelegate - override func tableView( +} + +// MARK: - UITableViewDelegate +extension FeedListViewController: UITableViewDelegate { + + // MARK: Functions + func tableView( _ tableView: UITableView, didSelectRowAt indexPath: IndexPath ) { @@ -183,13 +207,78 @@ private extension FeedListViewController { self?.activityIndicator.stopAnimating() self?.pullControl.endRefreshing() self?.tableView.reloadData() - self?.tableView.scrollToRow( - at: .init(row: 0, section: 0), - at: .middle, - animated: true - ) + + if self?.viewModel.state == .populated { + self?.tableView.scrollToRow( + at: .init(row: 0, section: 0), + at: .middle, + animated: true + ) + } } .store(in: &cancellables) + + viewModel.$state + .dropFirst() + .receive(on: RunLoop.main) + .sink { [weak self] state in + switch state { + case .empty, + .error: + self?.insertUnavailableView(state) + default: + self?.removeUnavailableView() + } + + } + .store(in: &cancellables) + } + + func insertUnavailableView(_ state: FeedListState) { + guard unavailableView == nil else { return } + + let isErrorState = state == .error + let unavailable = FeedUnavailableView( + systemImage: isErrorState + ? .Icon.error + : .Icon.empty, + title: NSLocalizedString( + isErrorState + ? .Key.Unavailable.Title.error + : .Key.Unavailable.Title.empty, + bundle: .module, + comment: .empty + ), + description: NSLocalizedString( + isErrorState + ? .Key.Unavailable.Description.error + : .Key.Unavailable.Description.empty, + bundle: .module, + comment: .empty + ), + button: isErrorState + ? NSLocalizedString( + .Key.Unavailable.Button.tryAgain, + bundle: .module, + comment: .empty + ) + : nil, + action: isErrorState + ? { self.viewModel.fetch() } + : nil + ) + + addChild(unavailable) + + view.addSubview(unavailable.view) + view.bringSubviewToFront(unavailable.view) + + // TODO: fix [this issue](https://stackoverflow.com/questions/70032739/why-does-swiftui-uihostingcontroller-have-extra-spacing) so autolayout can be used instead. + unavailable.view.frame = tableView.bounds + + unavailable.didMove(toParent: self) + + unavailableView = unavailable } func makeFeedItemCell( @@ -245,10 +334,22 @@ private extension FeedListViewController { TopWordsCell.register(in: tableView) } + func removeUnavailableView() { + guard let unavailableView else { return } + + unavailableView.view.removeFromSuperview() + + self.unavailableView = nil + } + func setLayout() { NSLayoutConstraint.activate([ activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), activityIndicator.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) } @@ -265,11 +366,14 @@ private extension FeedListViewController { } func setView() { - tableView.refreshControl = pullControl - + view.addSubview(tableView) view.addSubview(activityIndicator) view.bringSubviewToFront(activityIndicator) + tableView.delegate = self + tableView.refreshControl = pullControl + tableView.dataSource = self + activityIndicator.startAnimating() } @@ -309,6 +413,22 @@ private extension String { enum PullToRefresh { static let title = "view.feed-list.pull-to-refresh.title.text" } + + enum Unavailable { + enum Button { + static let tryAgain = "view.feed-list.unavailable.error.button.try-again.text" + } + + enum Description { + static let empty = "view.feed-list.unavailable.empty.description.text" + static let error = "view.feed-list.unavailable.empty.description.text" + } + + enum Title { + static let empty = "view.feed-list.unavailable.empty.title.text" + static let error = "view.feed-list.unavailable.error.title.text" + } + } } } @@ -333,7 +453,7 @@ import ReviewsiTunesKit @available(iOS 17.0, *) #Preview("Feed List loading reviews") { - MockURLProtocol.response = .init(statusCode: 200) + MockURLProtocol.response = .init(statusCode: 404) return UINavigationController(rootViewController: FeedListViewController(.init( configuration: .init(session: .mock) diff --git a/Libraries/UI/Kit/Sources/Extensions/String+Icons.swift b/Libraries/UI/Kit/Sources/Extensions/String+Icons.swift index 161b879..2e23fa8 100644 --- a/Libraries/UI/Kit/Sources/Extensions/String+Icons.swift +++ b/Libraries/UI/Kit/Sources/Extensions/String+Icons.swift @@ -10,6 +10,8 @@ public extension String { enum Icon { // MARK: Constants + public static let empty = "list.star" + public static let error = "exclamationmark.triangle.fill" public static let info = "info.circle.fill" public static let person = "person.crop.circle.fill" public static let starAll = "a.circle"