diff --git a/Frameworks/Feed/Bundle/Sources/Logic/Extensions/TopWord+DTOs.swift b/Frameworks/Feed/Bundle/Sources/Logic/Extensions/TopWord+DTOs.swift new file mode 100644 index 0000000..8f1fb6a --- /dev/null +++ b/Frameworks/Feed/Bundle/Sources/Logic/Extensions/TopWord+DTOs.swift @@ -0,0 +1,23 @@ +// +// TopWord+DTOs.swift +// ReviewsFeed +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import Foundation +import ReviewsFilterKit + +extension TopWord { + + // MARK: Initialisers + init(_ dto: WordCount) { + self = .init( + id: UUID().uuidString, + term: dto.word.term, + count: dto.count + ) + } + +} diff --git a/Frameworks/Feed/Bundle/Sources/Logic/Models/TopWord.swift b/Frameworks/Feed/Bundle/Sources/Logic/Models/TopWord.swift new file mode 100644 index 0000000..8db97e8 --- /dev/null +++ b/Frameworks/Feed/Bundle/Sources/Logic/Models/TopWord.swift @@ -0,0 +1,16 @@ +// +// TopWord.swift +// ReviewsFeed +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +struct TopWord: Identifiable { + + // MARK: Constants + let id: String + let term: String + let count: Int + +} diff --git a/Frameworks/Feed/Bundle/Sources/Logic/View Models/FeedListViewModel.swift b/Frameworks/Feed/Bundle/Sources/Logic/View Models/FeedListViewModel.swift index a36237a..a384c3a 100644 --- a/Frameworks/Feed/Bundle/Sources/Logic/View Models/FeedListViewModel.swift +++ b/Frameworks/Feed/Bundle/Sources/Logic/View Models/FeedListViewModel.swift @@ -18,6 +18,9 @@ extension FeedListViewController { // MARK: Constants private let configuration: Configuration + + private let filterWords: FilterWordsUseCase = .init() + private let topWords: TopWordsUseCase = .init() // MARK: Properties @Published var filter: FilterOption = .all @@ -26,9 +29,11 @@ extension FeedListViewController { @Published var isLoading: Bool = false var items: [Review] = [] + var words: [TopWord] = [] private var reviewsAll: [Review] = [] private var reviewsFiltered: FilteredReviews = [:] + private var reviewsTopWords: TopWordsReviews = [:] lazy private var iTunesService: iTunesService = { .init(configuration: .init(session: configuration.session)) @@ -38,6 +43,18 @@ extension FeedListViewController { init(configuration: Configuration = .init()) { self.configuration = configuration } + + // MARK: Computed + var itemsCount: Int { + isWordsShowing + ? items.count + : items.count + 1 + } + + var isWordsShowing: Bool { + filter != .all + && !words.isEmpty + } // MARK: Functions func fetch() { @@ -56,6 +73,14 @@ extension FeedListViewController { .reduce(into: FilteredReviews()) { partialResult, option in partialResult[option] = reviewsAll.filter { $0.rating.stars == option.rawValue } } + reviewsTopWords = reviewsFiltered + .mapValues { reviews in + reviews.map(\.comment) + .compactMap { try? filterWords($0) } + } + .mapValues { + topWords($0).map(TopWord.init) + } items = reviewsAll isFilterEnabled = !items.isEmpty @@ -74,9 +99,18 @@ extension FeedListViewController { items = option == .all ? reviewsAll : reviewsFiltered[option] ?? [] - + words = reviewsTopWords[option] ?? [] + filter = option } + + func item(for index: Int) -> Review? { + guard index < items.count else { return nil } + + return isWordsShowing + ? items[index - 1] + : items[index] + } } } @@ -86,5 +120,6 @@ private extension FeedListViewController.ViewModel { // MARK: Type aliases typealias FilteredReviews = [FilterOption: [Review]] + typealias TopWordsReviews = [FilterOption: [TopWord]] } diff --git a/Frameworks/Feed/Bundle/Sources/UI/Cells/FeedItemCell.swift b/Frameworks/Feed/Bundle/Sources/UI/Cells/FeedItemCell.swift new file mode 100644 index 0000000..a877df0 --- /dev/null +++ b/Frameworks/Feed/Bundle/Sources/UI/Cells/FeedItemCell.swift @@ -0,0 +1,17 @@ +// +// FeedItemCell.swift +// ReviewsFeed +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import ReviewsUIKit +import UIKit + +final class FeedItemCell: UITableViewCell, TableCell { + + // MARK: Constants + static let cellID = "FeedItemCell" + +} diff --git a/Frameworks/Feed/Bundle/Sources/UI/Cells/TopWordsCell.swift b/Frameworks/Feed/Bundle/Sources/UI/Cells/TopWordsCell.swift new file mode 100644 index 0000000..378ffd2 --- /dev/null +++ b/Frameworks/Feed/Bundle/Sources/UI/Cells/TopWordsCell.swift @@ -0,0 +1,17 @@ +// +// TopWordsCell.swift +// ReviewsFeed +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import ReviewsUIKit +import UIKit + +final class TopWordsCell: UITableViewCell, TableCell { + + // MARK: Constants + static let cellID = "TopWordsCell" + +} diff --git a/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift b/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift index ed019a8..eb78d32 100644 --- a/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift +++ b/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift @@ -40,11 +40,12 @@ public class FeedListViewController: UITableViewController { 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) - } + .init( + title: option.text, + image: .init(systemName: option.icon) + ) { [weak self] _ in + self?.viewModel.filter(by: option) + } } }() ) @@ -83,30 +84,18 @@ public class FeedListViewController: UITableViewController { _ tableView: UITableView, numberOfRowsInSection section: Int ) -> Int { - viewModel.items.count + viewModel.itemsCount } public override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: .Cell.feedItem) else { - return .init() + if viewModel.isWordsShowing && indexPath.row == 0 { + makeTopWordsCell(tableView) + } else { + makeFeedItemCell(tableView, at: indexPath.row) } - - cell.contentConfiguration = { - if #available(iOS 16.0, *) { - UIHostingConfiguration { - FeedItemCell(items[indexPath.row]) - } - } else { - HostingConfiguration { - FeedItemCell(items[indexPath.row]) - } - } - }() - - return cell } // MARK: UITableViewDelegate @@ -158,8 +147,57 @@ private extension FeedListViewController { .store(in: &cancellables) } + func makeFeedItemCell( + _ tableView: UITableView, + at row: Int + ) -> UITableViewCell { + guard + let cell = FeedItemCell.dequeue(from: tableView), + let item = viewModel.item(for: row) + else { + return .init() + } + + cell.separatorInset = .init(width: tableView.bounds.size.width) + cell.contentConfiguration = { + if #available(iOS 16.0, *) { + UIHostingConfiguration { + FeedItemView(item) + } + } else { + HostingConfiguration { + FeedItemView(item) + } + } + }() + + return cell + } + + func makeTopWordsCell(_ tableView: UITableView) -> UITableViewCell { + guard let cell = TopWordsCell.dequeue(from: tableView) else { + return .init() + } + + cell.separatorInset = .init(width: tableView.bounds.size.width) + cell.contentConfiguration = { + if #available(iOS 16.0, *) { + UIHostingConfiguration { + TopWordsView(viewModel.words) + } + } else { + HostingConfiguration { + TopWordsView(viewModel.words) + } + } + }() + + return cell + } + func registerTableCells() { - tableView.register(UITableViewCell.self, forCellReuseIdentifier: .Cell.feedItem) + FeedItemCell.register(in: tableView) + TopWordsCell.register(in: tableView) } func setNavigationBar() { @@ -232,6 +270,21 @@ private extension String { } } +// MARK: - UIEdgeInsets+Inits +private extension UIEdgeInsets { + + // MARK: Initialisers + init(width: CGFloat) { + self = .init( + top: 0, + left: width, + bottom: 0, + right: 0 + ) + } + +} + // MARK: - Previews #if DEBUG import ReviewsiTunesKit diff --git a/Frameworks/Feed/Bundle/Sources/UI/Components/Cells/FeedItemCell.swift b/Frameworks/Feed/Bundle/Sources/UI/Views/FeedItemView.swift similarity index 94% rename from Frameworks/Feed/Bundle/Sources/UI/Components/Cells/FeedItemCell.swift rename to Frameworks/Feed/Bundle/Sources/UI/Views/FeedItemView.swift index 4b5cef9..63fd399 100644 --- a/Frameworks/Feed/Bundle/Sources/UI/Components/Cells/FeedItemCell.swift +++ b/Frameworks/Feed/Bundle/Sources/UI/Views/FeedItemView.swift @@ -1,5 +1,5 @@ // -// FeedItemCell.swift +// FeedItemView.swift // ReviewsFeed // // Created by Javier Cicchelli on 19/03/2024. @@ -9,7 +9,7 @@ import ReviewsUIKit import SwiftUI -struct FeedItemCell: View { +struct FeedItemView: View { // MARK: Constants private let item: Review @@ -66,8 +66,8 @@ struct FeedItemCell: View { } // MARK: - Previews -#Preview("Feed Item Cell") { - FeedItemCell(.init( +#Preview("Feed Item") { + FeedItemView(.init( author: "Some author name here...", comment: "Some review comment here...", id: 0, diff --git a/Frameworks/Feed/Bundle/Sources/UI/Views/TopWordsView.swift b/Frameworks/Feed/Bundle/Sources/UI/Views/TopWordsView.swift new file mode 100644 index 0000000..95f9855 --- /dev/null +++ b/Frameworks/Feed/Bundle/Sources/UI/Views/TopWordsView.swift @@ -0,0 +1,82 @@ +// +// TopWordsView.swift +// ReviewsFeed +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import SwiftUI + +struct TopWordsView: View { + + // MARK: Constants + private let topWords: [TopWord] + + // MARK: Initialisers + init(_ topWords: [TopWord]) { + self.topWords = topWords + } + + // MARK: Body + var body: some View { + HStack { + Spacer() + + HStack(spacing: 12) { + ForEach(topWords) { topWord in + Item( + term: topWord.term, + count: String(topWord.count) + ) + } + } + + Spacer() + } + + } + +} + +// MARK: - Structs +private extension TopWordsView { + struct Item: View { + + // MARK: Constants + let term: String + let count: String + + // MARK: Body + var body: some View { + HStack( + alignment: .center, + spacing: 6 + ) { + Text(count) + .font(.footnote) + .foregroundColor(.primary.opacity(0.75)) + + Text(term) + .lineLimit(1) + .font(.body.bold()) + .foregroundColor(.primary) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.secondary.opacity(0.75)) + .cornerRadius(8) + } + + } +} + +// MARK: - Previews +#Preview { + TopWordsView([ + .init(id: "1", term: "Something", count: 3), + .init(id: "2", term: "Something", count: 2), + .init(id: "3", term: "Something", count: 1), + ]) + .padding(.horizontal) +} diff --git a/Libraries/UI/Kit/Sources/Protocols/TableCell.swift b/Libraries/UI/Kit/Sources/Protocols/TableCell.swift new file mode 100644 index 0000000..e158213 --- /dev/null +++ b/Libraries/UI/Kit/Sources/Protocols/TableCell.swift @@ -0,0 +1,33 @@ +// +// TableCell.swift +// ReviewsUIKit +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde VoF. All rights reserved. +// + +import UIKit + +public protocol TableCell: UITableViewCell { + + // MARK: Properties + static var cellID: String { get } + + // MARK: Functions + static func dequeue(from tableView: UITableView) -> UITableViewCell? + static func register(in tableView: UITableView) + +} + +// MARK: - Implementations +extension TableCell { + + public static func dequeue(from tableView: UITableView) -> UITableViewCell? { + tableView.dequeueReusableCell(withIdentifier: cellID) + } + + public static func register(in tableView: UITableView) { + tableView.register(Self.self, forCellReuseIdentifier: cellID) + } + +} diff --git a/Reviews.xcodeproj/project.pbxproj b/Reviews.xcodeproj/project.pbxproj index 9402628..f6bb822 100644 --- a/Reviews.xcodeproj/project.pbxproj +++ b/Reviews.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 0220ADA32BA90646001E6A9F /* FeedItemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0220ADA22BA90646001E6A9F /* FeedItemCell.swift */; }; + 0220ADA32BA90646001E6A9F /* FeedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0220ADA22BA90646001E6A9F /* FeedItemView.swift */; }; 023AC7FC2BAA3EC10027D064 /* Int+Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023AC7FB2BAA3EC10027D064 /* Int+Constants.swift */; }; 02620B8C2BA89C9A00DE7137 /* FeedListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02620B8B2BA89C9A00DE7137 /* FeedListViewModel.swift */; }; 02909E792BAB6B0200710E14 /* FilterOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02909E782BAB6B0200710E14 /* FilterOption.swift */; }; @@ -22,6 +22,11 @@ 02DC7FAF2BA51B4C000EEEBE /* Review.swift in Sources */ = {isa = PBXBuildFile; fileRef = 345AD13124C6EE64004E2EE1 /* Review.swift */; }; 02DC7FB32BA52518000EEEBE /* ReviewsKit in Frameworks */ = {isa = PBXBuildFile; productRef = 02DC7FB22BA52518000EEEBE /* ReviewsKit */; }; 02DC7FB52BA52520000EEEBE /* ReviewsKit in Frameworks */ = {isa = PBXBuildFile; productRef = 02DC7FB42BA52520000EEEBE /* ReviewsKit */; }; + 02EACF2E2BABA34600FF8ECD /* FeedItemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EACF2D2BABA34600FF8ECD /* FeedItemCell.swift */; }; + 02EACF302BABA50D00FF8ECD /* TopWordsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EACF2F2BABA50D00FF8ECD /* TopWordsCell.swift */; }; + 02EACF322BABB23A00FF8ECD /* TopWordsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EACF312BABB23A00FF8ECD /* TopWordsView.swift */; }; + 02EACF342BABB28900FF8ECD /* TopWord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EACF332BABB28900FF8ECD /* TopWord.swift */; }; + 02EACF362BABB2F200FF8ECD /* TopWord+DTOs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EACF352BABB2F200FF8ECD /* TopWord+DTOs.swift */; }; 345AD11C24C6EDD9004E2EE1 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 345AD11B24C6EDD9004E2EE1 /* AppDelegate.swift */; }; 345AD12524C6EDDC004E2EE1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 345AD12424C6EDDC004E2EE1 /* Assets.xcassets */; }; 345AD12824C6EDDC004E2EE1 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 345AD12624C6EDDC004E2EE1 /* LaunchScreen.storyboard */; }; @@ -52,7 +57,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 0220ADA22BA90646001E6A9F /* FeedItemCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemCell.swift; sourceTree = ""; }; + 0220ADA22BA90646001E6A9F /* FeedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemView.swift; sourceTree = ""; }; 023AC7FB2BAA3EC10027D064 /* Int+Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+Constants.swift"; sourceTree = ""; }; 02620B8B2BA89C9A00DE7137 /* FeedListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListViewModel.swift; sourceTree = ""; }; 02909E782BAB6B0200710E14 /* FilterOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterOption.swift; sourceTree = ""; }; @@ -62,6 +67,11 @@ 02DC7F8F2BA51793000EEEBE /* ReviewsFeed.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ReviewsFeed.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 02DC7F912BA51793000EEEBE /* ReviewsFeed.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ReviewsFeed.h; sourceTree = ""; }; 02DC7FB12BA52084000EEEBE /* Libraries */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Libraries; sourceTree = ""; }; + 02EACF2D2BABA34600FF8ECD /* FeedItemCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemCell.swift; sourceTree = ""; }; + 02EACF2F2BABA50D00FF8ECD /* TopWordsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopWordsCell.swift; sourceTree = ""; }; + 02EACF312BABB23A00FF8ECD /* TopWordsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopWordsView.swift; sourceTree = ""; }; + 02EACF332BABB28900FF8ECD /* TopWord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopWord.swift; sourceTree = ""; }; + 02EACF352BABB2F200FF8ECD /* TopWord+DTOs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TopWord+DTOs.swift"; sourceTree = ""; }; 345AD11824C6EDD9004E2EE1 /* Reviews.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Reviews.app; sourceTree = BUILT_PRODUCTS_DIR; }; 345AD11B24C6EDD9004E2EE1 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 345AD12424C6EDDC004E2EE1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -93,20 +103,13 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 0220ADA72BA98F8B001E6A9F /* Cells */ = { - isa = PBXGroup; - children = ( - 0220ADA22BA90646001E6A9F /* FeedItemCell.swift */, - ); - path = Cells; - sourceTree = ""; - }; 023AC7FA2BAA3EB60027D064 /* Extensions */ = { isa = PBXGroup; children = ( 02909E7A2BAB6D2E00710E14 /* Bundle+Constants.swift */, 023AC7FB2BAA3EC10027D064 /* Int+Constants.swift */, 02909E7C2BAB7FFE00710E14 /* Review+DTOs.swift */, + 02EACF352BABB2F200FF8ECD /* TopWord+DTOs.swift */, ); path = Extensions; sourceTree = ""; @@ -114,7 +117,8 @@ 02620B852BA89BF900DE7137 /* UI */ = { isa = PBXGroup; children = ( - 02620B892BA89C2400DE7137 /* Components */, + 02EACF2C2BABA32A00FF8ECD /* Cells */, + 02620B892BA89C2400DE7137 /* Views */, 02620B882BA89C1000DE7137 /* View Controllers */, ); path = UI; @@ -148,18 +152,20 @@ path = "View Controllers"; sourceTree = ""; }; - 02620B892BA89C2400DE7137 /* Components */ = { + 02620B892BA89C2400DE7137 /* Views */ = { isa = PBXGroup; children = ( - 0220ADA72BA98F8B001E6A9F /* Cells */, + 0220ADA22BA90646001E6A9F /* FeedItemView.swift */, + 02EACF312BABB23A00FF8ECD /* TopWordsView.swift */, ); - path = Components; + path = Views; sourceTree = ""; }; 02620B8A2BA89C3300DE7137 /* Models */ = { isa = PBXGroup; children = ( 345AD13124C6EE64004E2EE1 /* Review.swift */, + 02EACF332BABB28900FF8ECD /* TopWord.swift */, ); path = Models; sourceTree = ""; @@ -273,6 +279,15 @@ path = Sources; sourceTree = ""; }; + 02EACF2C2BABA32A00FF8ECD /* Cells */ = { + isa = PBXGroup; + children = ( + 02EACF2D2BABA34600FF8ECD /* FeedItemCell.swift */, + 02EACF2F2BABA50D00FF8ECD /* TopWordsCell.swift */, + ); + path = Cells; + sourceTree = ""; + }; 345AD10F24C6EDD9004E2EE1 = { isa = PBXGroup; children = ( @@ -421,14 +436,19 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 02EACF302BABA50D00FF8ECD /* TopWordsCell.swift in Sources */, 02620B8C2BA89C9A00DE7137 /* FeedListViewModel.swift in Sources */, + 02EACF342BABB28900FF8ECD /* TopWord.swift in Sources */, 023AC7FC2BAA3EC10027D064 /* Int+Constants.swift in Sources */, 02DC7FAC2BA51B4C000EEEBE /* FeedItemViewController.swift in Sources */, 02909E7B2BAB6D2E00710E14 /* Bundle+Constants.swift in Sources */, + 02EACF2E2BABA34600FF8ECD /* FeedItemCell.swift in Sources */, 02909E7D2BAB7FFE00710E14 /* Review+DTOs.swift in Sources */, - 0220ADA32BA90646001E6A9F /* FeedItemCell.swift in Sources */, + 0220ADA32BA90646001E6A9F /* FeedItemView.swift in Sources */, + 02EACF362BABB2F200FF8ECD /* TopWord+DTOs.swift in Sources */, 02DC7FAF2BA51B4C000EEEBE /* Review.swift in Sources */, 02DC7FAE2BA51B4C000EEEBE /* FeedListViewController.swift in Sources */, + 02EACF322BABB23A00FF8ECD /* TopWordsView.swift in Sources */, 02909E792BAB6B0200710E14 /* FilterOption.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0;