[Framework] Empty and error state handling for the Feed list #18

Merged
javier merged 5 commits from framework/feed/empty+error into main 2024-03-22 09:14:23 +00:00
8 changed files with 404 additions and 39 deletions

View File

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

View File

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

View File

@ -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] = []
@ -90,9 +91,12 @@ extension FeedListViewController {
: 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]

View File

@ -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<AnyCancellable> = []
// MARK: Outlets
@ -46,16 +48,14 @@ 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)
}
}
}()
}
)
)
}()
@ -78,11 +78,25 @@ 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) {
@ -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()))

View File

@ -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<UnavailableView> {
// 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
)
}
}
}

View File

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

View File

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

View File

@ -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 = "<group>"; };
028134812BACCC770074AB4B /* FeedListCoordination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListCoordination.swift; sourceTree = "<group>"; };
028134832BACD0B20074AB4B /* FeedItemCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemCoordinator.swift; sourceTree = "<group>"; };
028134872BAD01A60074AB4B /* FeedListState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListState.swift; sourceTree = "<group>"; };
028134892BAD08AB0074AB4B /* FeedUnavailableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedUnavailableView.swift; sourceTree = "<group>"; };
02909E782BAB6B0200710E14 /* FilterOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterOption.swift; sourceTree = "<group>"; };
02909E7A2BAB6D2E00710E14 /* Bundle+Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Constants.swift"; sourceTree = "<group>"; };
02909E7C2BAB7FFE00710E14 /* Review+DTOs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Review+DTOs.swift"; sourceTree = "<group>"; };
@ -168,6 +172,7 @@
children = (
0220ADA22BA90646001E6A9F /* FeedItemView.swift */,
02EACF312BABB23A00FF8ECD /* TopWordsView.swift */,
028134892BAD08AB0074AB4B /* FeedUnavailableView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -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;