[Framework] Feed item filtering in the Feed List view (#11)
This PR contains the work done to implement the filtering of the items shown in the `FeedListViewController` view controller by star rating. Reviewed-on: rock-n-code/app-reviews#11 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com> Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"common.filter.menu.all-reviews.action.title.text" : {
|
||||
"comment" : "The title for the All Reviews action inside the Filter menu in the Feed List view",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "All reviews"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"common.filter.menu.only-1-star-reviews.action.title.text" : {
|
||||
"comment" : "The title for the 1-Star Only Reviews action inside the Filter menu in the Feed List view",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "1-star reviews only "
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"common.filter.menu.only-2-star-reviews.action.title.text" : {
|
||||
"comment" : "The title for the 2-Star Only Reviews action inside the Filter menu in the Feed List view",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "2-star reviews only"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"common.filter.menu.only-3-star-reviews.action.title.text" : {
|
||||
"comment" : "The title for the 3-Star Only Reviews action inside the Filter menu in the Feed List view",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "3-star reviews only"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"common.filter.menu.only-4-star-reviews.action.title.text" : {
|
||||
"comment" : "The title for the 4-Star Only Reviews action inside the Filter menu in the Feed List view",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "4-star reviews only"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"common.filter.menu.only-5-star-reviews.action.title.text" : {
|
||||
"comment" : "The title for the 5-Star Only Reviews action inside the Filter menu in the Feed List view",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "5-star reviews only"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"common.filter.menu.title.text" : {
|
||||
"comment" : "The title for the Filter menu option in the Feed List view.",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Filter review by star rating"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"view.feed-list.navigation-bar.button.filter-list.text" : {
|
||||
"comment" : "The text for the Filter button at the navigation bar in the Feed List view.",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Filter items"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"view.feed-list.navigation-bar.title.text" : {
|
||||
"comment" : "The title for the navigation bar in the Feed List view.",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Latest reviews"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// FilterOption.swift
|
||||
// ReviewsFeed
|
||||
//
|
||||
// Created by Javier Cicchelli on 20/03/2024.
|
||||
// Copyright © 2024 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ReviewsFoundationKit
|
||||
import ReviewsUIKit
|
||||
import UIKit
|
||||
|
||||
enum FilterOption: Int, CaseIterable {
|
||||
case all = 0
|
||||
case only1Star
|
||||
case only2Star
|
||||
case only3Star
|
||||
case only4Star
|
||||
case only5Star
|
||||
}
|
||||
|
||||
// MARK: - Computed
|
||||
extension FilterOption {
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .all: .Icon.starAll
|
||||
case .only1Star: .Icon.star1
|
||||
case .only2Star: .Icon.star2
|
||||
case .only3Star: .Icon.star3
|
||||
case .only4Star: .Icon.star4
|
||||
case .only5Star: .Icon.star5
|
||||
}
|
||||
}
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .all: NSLocalizedString(
|
||||
.Key.Menu.Action.allReviews,
|
||||
bundle: .module,
|
||||
comment: .empty
|
||||
)
|
||||
case .only1Star: NSLocalizedString(
|
||||
.Key.Menu.Action.only1StarReviews,
|
||||
bundle: .module,
|
||||
comment: .empty
|
||||
)
|
||||
case .only2Star: NSLocalizedString(
|
||||
.Key.Menu.Action.only2StarReviews,
|
||||
bundle: .module,
|
||||
comment: .empty
|
||||
)
|
||||
case .only3Star: NSLocalizedString(
|
||||
.Key.Menu.Action.only3StarReviews,
|
||||
bundle: .module,
|
||||
comment: .empty
|
||||
)
|
||||
case .only4Star: NSLocalizedString(
|
||||
.Key.Menu.Action.only4StarReviews,
|
||||
bundle: .module,
|
||||
comment: .empty
|
||||
)
|
||||
case .only5Star: NSLocalizedString(
|
||||
.Key.Menu.Action.only5StarReviews,
|
||||
bundle: .module,
|
||||
comment: .empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - String+Constants
|
||||
private extension String {
|
||||
enum Key {
|
||||
enum Menu {
|
||||
enum Action {
|
||||
static let allReviews = "common.filter.menu.all-reviews.action.title.text"
|
||||
static let only1StarReviews = "common.filter.menu.only-1-star-reviews.action.title.text"
|
||||
static let only2StarReviews = "common.filter.menu.only-2-star-reviews.action.title.text"
|
||||
static let only3StarReviews = "common.filter.menu.only-3-star-reviews.action.title.text"
|
||||
static let only4StarReviews = "common.filter.menu.only-4-star-reviews.action.title.text"
|
||||
static let only5StarReviews = "common.filter.menu.only-5-star-reviews.action.title.text"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// Bundle+Constants.swift
|
||||
// ReviewsFeed
|
||||
//
|
||||
// Created by Javier Cicchelli on 20/03/2024.
|
||||
// Copyright © 2024 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Bundle {
|
||||
|
||||
// MARK: Constants
|
||||
static let module = Bundle(for: FeedItemViewController.self)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// Review+DTOs.swift
|
||||
// ReviewsFeed
|
||||
//
|
||||
// Created by Javier Cicchelli on 20/03/2024.
|
||||
// Copyright © 2024 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import ReviewsFeedKit
|
||||
|
||||
extension Review {
|
||||
|
||||
// MARK: Initialisers
|
||||
init(_ dto: ReviewsFeedKit.Review) {
|
||||
self = .init(
|
||||
author: dto.author,
|
||||
comment: dto.content,
|
||||
id: dto.id,
|
||||
rating: .init(
|
||||
stars: dto.rating,
|
||||
appVersion: dto.version
|
||||
),
|
||||
title: dto.title
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// FeedListViewModel.swift
|
||||
// ReviewsFeed
|
||||
//
|
||||
// Created by Javier Cicchelli on 18/03/2024.
|
||||
// Copyright © 2024 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ReviewsFilterKit
|
||||
import ReviewsiTunesKit
|
||||
|
||||
extension FeedListViewController {
|
||||
final class ViewModel: ObservableObject {
|
||||
|
||||
// MARK: Type aliases
|
||||
typealias Configuration = FeedListViewController.Configuration
|
||||
|
||||
// MARK: Constants
|
||||
private let configuration: Configuration
|
||||
|
||||
// MARK: Properties
|
||||
@Published var filter: FilterOption = .all
|
||||
@Published var isFilterEnabled: Bool = false
|
||||
@Published var isFiltering: Bool = false
|
||||
@Published var isLoading: Bool = false
|
||||
|
||||
var items: [Review] = []
|
||||
|
||||
private var reviewsAll: [Review] = []
|
||||
private var reviewsFiltered: FilteredReviews = [:]
|
||||
|
||||
lazy private var iTunesService: iTunesService = {
|
||||
.init(configuration: .init(session: configuration.session))
|
||||
}()
|
||||
|
||||
// MARK: Initialisers
|
||||
init(configuration: Configuration = .init()) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
func fetch() {
|
||||
Task {
|
||||
isFilterEnabled = false
|
||||
isLoading = items.isEmpty
|
||||
|
||||
do {
|
||||
let output = try await iTunesService.getReviews(.init(
|
||||
appID: configuration.appID,
|
||||
countryCode: configuration.countryCode
|
||||
))
|
||||
|
||||
reviewsAll = output.reviews.map(Review.init)
|
||||
reviewsFiltered = FilterOption.allCases
|
||||
.reduce(into: FilteredReviews()) { partialResult, option in
|
||||
partialResult[option] = reviewsAll.filter { $0.rating.stars == option.rawValue }
|
||||
}
|
||||
|
||||
items = reviewsAll
|
||||
isFilterEnabled = !items.isEmpty
|
||||
} catch {
|
||||
// TODO: handle this error gracefully.
|
||||
print("ERROR: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
func filter(by option: FilterOption) {
|
||||
guard option != filter else { return }
|
||||
|
||||
items = option == .all
|
||||
? reviewsAll
|
||||
: reviewsFiltered[option] ?? []
|
||||
|
||||
filter = option
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private extension FeedListViewController.ViewModel {
|
||||
|
||||
// MARK: Type aliases
|
||||
typealias FilteredReviews = [FilterOption: [Review]]
|
||||
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
//
|
||||
// FeedViewModel.swift
|
||||
// ReviewsFeed
|
||||
//
|
||||
// Created by Javier Cicchelli on 18/03/2024.
|
||||
// Copyright © 2024 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ReviewsiTunesKit
|
||||
|
||||
extension FeedListViewController {
|
||||
final class ViewModel: ObservableObject {
|
||||
|
||||
// MARK: Type aliases
|
||||
typealias Configuration = FeedListViewController.Configuration
|
||||
|
||||
// MARK: Constants
|
||||
private let configuration: Configuration
|
||||
|
||||
// MARK: Properties
|
||||
@Published var loading: Bool = false
|
||||
|
||||
var items: [Review] = []
|
||||
|
||||
// MARK: Initialisers
|
||||
init(configuration: Configuration = .init()) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
lazy private var iTunesService: iTunesService = {
|
||||
.init(configuration: .init(session: configuration.session))
|
||||
}()
|
||||
|
||||
// MARK: Functions
|
||||
func fetch() {
|
||||
Task {
|
||||
loading = true
|
||||
|
||||
do {
|
||||
let output = try await iTunesService.getReviews(.init(
|
||||
appID: configuration.appID,
|
||||
countryCode: configuration.countryCode
|
||||
))
|
||||
|
||||
items = output.reviews
|
||||
.map { review -> Review in
|
||||
.init(
|
||||
author: review.author,
|
||||
comment: review.content,
|
||||
id: review.id,
|
||||
rating: .init(
|
||||
stars: review.rating,
|
||||
appVersion: review.version
|
||||
),
|
||||
title: review.title
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// TODO: handle this error gracefully.
|
||||
}
|
||||
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import ReviewsFoundationKit
|
||||
import ReviewsUIKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
@@ -19,6 +21,36 @@ public class FeedListViewController: UITableViewController {
|
||||
// MARK: Properties
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
// MARK: Outlets
|
||||
private lazy var filterButton = {
|
||||
UIBarButtonItem(
|
||||
title: NSLocalizedString(
|
||||
.Key.Navigation.Button.filter,
|
||||
bundle: .module,
|
||||
comment: .empty
|
||||
),
|
||||
image: .Icon.filter,
|
||||
primaryAction: nil,
|
||||
menu: .init(
|
||||
title: NSLocalizedString(
|
||||
.Key.Menu.filter,
|
||||
bundle: .module,
|
||||
comment: .empty
|
||||
),
|
||||
image: UIImage.Icon.star,
|
||||
children: {
|
||||
FilterOption.allCases.map { option -> UIAction in
|
||||
.init(title: option.text,
|
||||
image: .init(systemName: option.icon)
|
||||
) { [weak self] _ in
|
||||
self?.viewModel.filter(by: option)
|
||||
}
|
||||
}
|
||||
}()
|
||||
)
|
||||
)
|
||||
}()
|
||||
|
||||
// MARK: Initialisers
|
||||
public init(configuration: Configuration = .init()) {
|
||||
self.viewModel = .init(configuration: configuration)
|
||||
@@ -30,6 +62,11 @@ public class FeedListViewController: UITableViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
var items: [Review] {
|
||||
viewModel.items
|
||||
}
|
||||
|
||||
// MARK: UIViewController
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
@@ -60,11 +97,11 @@ public class FeedListViewController: UITableViewController {
|
||||
cell.contentConfiguration = {
|
||||
if #available(iOS 16.0, *) {
|
||||
UIHostingConfiguration {
|
||||
FeedItemCell(viewModel.items[indexPath.row])
|
||||
FeedItemCell(items[indexPath.row])
|
||||
}
|
||||
} else {
|
||||
HostingConfiguration {
|
||||
FeedItemCell(viewModel.items[indexPath.row])
|
||||
FeedItemCell(items[indexPath.row])
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -77,7 +114,7 @@ public class FeedListViewController: UITableViewController {
|
||||
_ tableView: UITableView,
|
||||
didSelectRowAt indexPath: IndexPath
|
||||
) {
|
||||
let details = FeedItemViewController(viewModel.items[indexPath.row])
|
||||
let details = FeedItemViewController(items[indexPath.row])
|
||||
|
||||
tableView.deselectRow(
|
||||
at: indexPath,
|
||||
@@ -94,15 +131,26 @@ private extension FeedListViewController {
|
||||
|
||||
// MARK: Functions
|
||||
func bindViewModel() {
|
||||
viewModel.$loading
|
||||
.sink { loading in
|
||||
print("LOADING: \(loading)")
|
||||
viewModel.$filter
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] option in
|
||||
self?.updateFilterMenu(option)
|
||||
self?.tableView.reloadData()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$isFilterEnabled
|
||||
.removeDuplicates()
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] enabled in
|
||||
self?.filterButton.isEnabled = enabled
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$loading
|
||||
viewModel.$isLoading
|
||||
.dropFirst()
|
||||
.filter { $0 == false }
|
||||
.removeDuplicates()
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.tableView.reloadData()
|
||||
@@ -118,7 +166,24 @@ private extension FeedListViewController {
|
||||
navigationController?.navigationBar.prefersLargeTitles = true
|
||||
navigationController?.navigationBar.isTranslucent = true
|
||||
|
||||
navigationItem.title = "Latest reviews"
|
||||
navigationItem.rightBarButtonItem = filterButton
|
||||
navigationItem.title = NSLocalizedString(
|
||||
.Key.Navigation.title,
|
||||
bundle: .module,
|
||||
comment: .empty
|
||||
)
|
||||
}
|
||||
|
||||
func updateFilterMenu(_ option: FilterOption) {
|
||||
filterButton
|
||||
.menu?
|
||||
.children
|
||||
.compactMap { $0 as? UIAction }
|
||||
.forEach { action in
|
||||
action.state = action.title == option.text
|
||||
? .on
|
||||
: .off
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -151,11 +216,24 @@ private extension String {
|
||||
enum Cell {
|
||||
static let feedItem = "FeedItemCell"
|
||||
}
|
||||
|
||||
enum Key {
|
||||
enum Menu {
|
||||
static let filter = "common.filter.menu.title.text"
|
||||
}
|
||||
|
||||
enum Navigation {
|
||||
static let title = "view.feed-list.navigation-bar.title.text"
|
||||
|
||||
enum Button {
|
||||
static let filter = "view.feed-list.navigation-bar.button.filter-list.text"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
#if DEBUG
|
||||
import ReviewsFoundationKit
|
||||
import ReviewsiTunesKit
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
|
||||
Reference in New Issue
Block a user