From 050e7e96203d3670f31e8634c2c10a27ba28c830 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Fri, 22 Mar 2024 09:14:22 +0000 Subject: [PATCH] [Framework] Empty and error state handling for the Feed list (#18) This PR contains the work done to implement the handling of empty and error states for the `FeedListViewController` view controller in the `Feed` framework. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/app-reviews/pulls/18 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../Resources/Catalogs/Localizable.xcstrings | 60 ++++++ .../Logic/Enumerations/FeedListState.swift | 14 ++ .../Logic/View Models/FeedListViewModel.swift | 17 +- .../FeedListViewController.swift | 190 ++++++++++++++---- .../UI/Views/FeedUnavailableView.swift | 47 +++++ .../Kit/Sources/Extensions/String+Icons.swift | 2 + .../Kit/Sources/Views/UnavailableView.swift | 105 ++++++++++ Reviews.xcodeproj/project.pbxproj | 8 + 8 files changed, 404 insertions(+), 39 deletions(-) create mode 100644 Frameworks/Feed/Bundle/Sources/Logic/Enumerations/FeedListState.swift create mode 100644 Frameworks/Feed/Bundle/Sources/UI/Views/FeedUnavailableView.swift create mode 100644 Libraries/UI/Kit/Sources/Views/UnavailableView.swift 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/Logic/Enumerations/FeedListState.swift b/Frameworks/Feed/Bundle/Sources/Logic/Enumerations/FeedListState.swift new file mode 100644 index 0000000..1f177ee --- /dev/null +++ b/Frameworks/Feed/Bundle/Sources/Logic/Enumerations/FeedListState.swift @@ -0,0 +1,14 @@ +// +// FeedListState.swift +// ReviewsFeed +// +// Created by Javier Cicchelli on 22/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +enum FeedListState { + case empty + case error + case initial + case populated +} diff --git a/Frameworks/Feed/Bundle/Sources/Logic/View Models/FeedListViewModel.swift b/Frameworks/Feed/Bundle/Sources/Logic/View Models/FeedListViewModel.swift index 08671b7..c561eac 100644 --- a/Frameworks/Feed/Bundle/Sources/Logic/View Models/FeedListViewModel.swift +++ b/Frameworks/Feed/Bundle/Sources/Logic/View Models/FeedListViewModel.swift @@ -24,6 +24,7 @@ extension FeedListViewController { @Published var isFilterEnabled: Bool = false @Published var isFiltering: Bool = false @Published var isLoading: Bool = false + @Published var state: FeedListState = .initial var items: [Review] = [] var words: [TopWord] = [] @@ -88,11 +89,14 @@ extension FeedListViewController { items = filter == .all ? reviewsAll : reviewsFiltered[filter] ?? [] - + isFilterEnabled = !items.isEmpty + state = items.isEmpty + ? .empty + : .populated } catch { - // TODO: handle this error gracefully. - print("ERROR: \(error.localizedDescription)") + items = [] + state = .error } isLoading = false @@ -111,7 +115,12 @@ extension FeedListViewController { } func item(for index: Int) -> Review? { - guard index < items.count else { return nil } + guard + !items.isEmpty, + index < items.count + else { + return nil + } return isWordsShowing ? items[index - 1] diff --git a/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift b/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift index 4fee1d3..a9e662f 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" + } + } } } @@ -331,15 +451,6 @@ private extension UIEdgeInsets { #if DEBUG import ReviewsiTunesKit -@available(iOS 17.0, *) -#Preview("Feed List loading reviews") { - MockURLProtocol.response = .init(statusCode: 200) - - return UINavigationController(rootViewController: FeedListViewController(.init( - configuration: .init(session: .mock) - ))) -} - @available(iOS 17.0, *) #Preview("Feed List with few reviews") { MockURLProtocol.response = .init( @@ -410,6 +521,15 @@ import ReviewsiTunesKit ))) } +@available(iOS 17.0, *) +#Preview("Feed List with error when loading reviews") { + MockURLProtocol.response = .init(statusCode: 404) + + return UINavigationController(rootViewController: FeedListViewController(.init( + configuration: .init(session: .mock) + ))) +} + @available(iOS 17.0, *) #Preview("Feed List with live reviews") { return UINavigationController(rootViewController: FeedListViewController(.init())) diff --git a/Frameworks/Feed/Bundle/Sources/UI/Views/FeedUnavailableView.swift b/Frameworks/Feed/Bundle/Sources/UI/Views/FeedUnavailableView.swift new file mode 100644 index 0000000..0c7cf47 --- /dev/null +++ b/Frameworks/Feed/Bundle/Sources/UI/Views/FeedUnavailableView.swift @@ -0,0 +1,47 @@ +// +// FeedUnavailableView.swift +// Feed +// +// Created by Javier Cicchelli on 22/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import ReviewsUIKit +import SwiftUI +import UIKit + + +final class FeedUnavailableView: UIHostingController { + + // MARK: Initialisers + convenience init( + systemImage: String, + title: String, + description: String, + button: String? = nil, + action: (() -> Void)? = nil + ) { + self.init(rootView: .init( + systemImage: systemImage, + title: title, + description: description, + button: button, + action: action + )) + } + + // MARK: Functions + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + if view.safeAreaInsets.bottom > 0 { + additionalSafeAreaInsets = .init( + top: 0, + left: 0, + bottom: -view.safeAreaInsets.bottom, + right: 0 + ) + } + } + +} 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" diff --git a/Libraries/UI/Kit/Sources/Views/UnavailableView.swift b/Libraries/UI/Kit/Sources/Views/UnavailableView.swift new file mode 100644 index 0000000..0dd103e --- /dev/null +++ b/Libraries/UI/Kit/Sources/Views/UnavailableView.swift @@ -0,0 +1,105 @@ +// +// UnavailableView.swift +// ReviewsUIKit +// +// Created by Javier Cicchelli on 22/03/2024. +// Copyright © 2024 Röck+Cöde VoF. All rights reserved. +// + +import SwiftUI + +public struct UnavailableView: View { + + // MARK: Constants + private let description: String + private let systemImage: String + private let title: String + + private let action: (() -> Void)? + private let button: String? + + // MARK: Initialisers + public init( + systemImage: String, + title: String, + description: String, + button: String? = nil, + action: (() -> Void)? = nil + ) { + self.action = action + self.button = button + self.description = description + self.systemImage = systemImage + self.title = title + } + + // MARK: Body + public var body: some View { + if #available(iOS 17.0, *) { + if let button, let action { + ContentUnavailableView { + Label(title, systemImage: systemImage) + } description: { + Text(description) + } actions: { + Button(action: action) { + Text(button) + } + } + } else { + ContentUnavailableView( + title, + systemImage: systemImage, + description: Text(description) + ) + } + } else { + VStack(spacing: 24) { + Image(systemName: systemImage) + .resizable() + .frame(width: 52, height: 52) + .foregroundColor(.secondary) + + VStack { + Text(title) + .font(.title2.bold()) + .foregroundColor(.primary) + + Text(description) + .font(.subheadline) + .foregroundColor(.secondary) + } + .multilineTextAlignment(.center) + + if let button, let action { + Button(action: action) { + Text(button) + .font(.subheadline) + } + } + } + .padding(.horizontal, 52) + } + } + +} + +// MARK: - Previews +#Preview("Unavailable View with image, title, and description") { + UnavailableView( + systemImage: "star", + title: "Some title goes here...", + description: "Some long, explanatory description goes in here..." + ) +} + +#Preview("Unavailable View with image, title, description, button, and action") { + UnavailableView( + systemImage: "star", + title: "Some title goes here...", + description: "Some long, explanatory description goes in here...", + button: "Some button text here..." + ) { + // On action closure. + } +} diff --git a/Reviews.xcodeproj/project.pbxproj b/Reviews.xcodeproj/project.pbxproj index d05c3f5..0a47e76 100644 --- a/Reviews.xcodeproj/project.pbxproj +++ b/Reviews.xcodeproj/project.pbxproj @@ -13,6 +13,8 @@ 028134712BACC8CC0074AB4B /* FeedListConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028134702BACC8CC0074AB4B /* FeedListConfiguration.swift */; }; 028134822BACCC780074AB4B /* FeedListCoordination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028134812BACCC770074AB4B /* FeedListCoordination.swift */; }; 028134842BACD0B20074AB4B /* FeedItemCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028134832BACD0B20074AB4B /* FeedItemCoordinator.swift */; }; + 028134882BAD01A60074AB4B /* FeedListState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028134872BAD01A60074AB4B /* FeedListState.swift */; }; + 0281348A2BAD08AB0074AB4B /* FeedUnavailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028134892BAD08AB0074AB4B /* FeedUnavailableView.swift */; }; 02909E792BAB6B0200710E14 /* FilterOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02909E782BAB6B0200710E14 /* FilterOption.swift */; }; 02909E7B2BAB6D2E00710E14 /* Bundle+Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02909E7A2BAB6D2E00710E14 /* Bundle+Constants.swift */; }; 02909E7D2BAB7FFE00710E14 /* Review+DTOs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02909E7C2BAB7FFE00710E14 /* Review+DTOs.swift */; }; @@ -68,6 +70,8 @@ 028134702BACC8CC0074AB4B /* FeedListConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListConfiguration.swift; sourceTree = ""; }; 028134812BACCC770074AB4B /* FeedListCoordination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListCoordination.swift; sourceTree = ""; }; 028134832BACD0B20074AB4B /* FeedItemCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemCoordinator.swift; sourceTree = ""; }; + 028134872BAD01A60074AB4B /* FeedListState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListState.swift; sourceTree = ""; }; + 028134892BAD08AB0074AB4B /* FeedUnavailableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedUnavailableView.swift; sourceTree = ""; }; 02909E782BAB6B0200710E14 /* FilterOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterOption.swift; sourceTree = ""; }; 02909E7A2BAB6D2E00710E14 /* Bundle+Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Constants.swift"; sourceTree = ""; }; 02909E7C2BAB7FFE00710E14 /* Review+DTOs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Review+DTOs.swift"; sourceTree = ""; }; @@ -168,6 +172,7 @@ children = ( 0220ADA22BA90646001E6A9F /* FeedItemView.swift */, 02EACF312BABB23A00FF8ECD /* TopWordsView.swift */, + 028134892BAD08AB0074AB4B /* FeedUnavailableView.swift */, ); path = Views; sourceTree = ""; @@ -200,6 +205,7 @@ 02909E772BAB6AD500710E14 /* Enumerations */ = { isa = PBXGroup; children = ( + 028134872BAD01A60074AB4B /* FeedListState.swift */, 02909E782BAB6B0200710E14 /* FilterOption.swift */, ); path = Enumerations; @@ -498,6 +504,7 @@ 02DC7FAC2BA51B4C000EEEBE /* FeedItemViewController.swift in Sources */, 02909E7B2BAB6D2E00710E14 /* Bundle+Constants.swift in Sources */, 02EACF2E2BABA34600FF8ECD /* FeedItemCell.swift in Sources */, + 0281348A2BAD08AB0074AB4B /* FeedUnavailableView.swift in Sources */, 028134842BACD0B20074AB4B /* FeedItemCoordinator.swift in Sources */, 02909E7D2BAB7FFE00710E14 /* Review+DTOs.swift in Sources */, 0220ADA32BA90646001E6A9F /* FeedItemView.swift in Sources */, @@ -508,6 +515,7 @@ 02EACF322BABB23A00FF8ECD /* TopWordsView.swift in Sources */, 028134712BACC8CC0074AB4B /* FeedListConfiguration.swift in Sources */, 028134822BACCC780074AB4B /* FeedListCoordination.swift in Sources */, + 028134882BAD01A60074AB4B /* FeedListState.swift in Sources */, 02909E792BAB6B0200710E14 /* FilterOption.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0;