diff --git a/App/Sources/App/AppDelegate.swift b/App/Sources/App/AppDelegate.swift index e0dfd38..83efe14 100644 --- a/App/Sources/App/AppDelegate.swift +++ b/App/Sources/App/AppDelegate.swift @@ -37,6 +37,7 @@ extension AppDelegate: UIApplicationDelegate { coordinator.present(animated: false) coordinator.present( child: FeedListCoordinator( + configuration: .init(session: .ephemeral), router: StackRouter(coordinator.navigationController) ), animated: false diff --git a/Frameworks/Feed/Bundle/Sources/Coordinators/Coordination/FeedListCoordination.swift b/Frameworks/Feed/Bundle/Sources/Coordinators/Coordination/FeedListCoordination.swift new file mode 100644 index 0000000..ffb5cf8 --- /dev/null +++ b/Frameworks/Feed/Bundle/Sources/Coordinators/Coordination/FeedListCoordination.swift @@ -0,0 +1,16 @@ +// +// FeedListCoordination.swift +// ReviewsFeed +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import Foundation + +protocol FeedListCoordination: AnyObject { + + // MARK: Functions + func open(_ item: Review) + +} diff --git a/Frameworks/Feed/Bundle/Sources/Coordinators/FeedItemCoordinator.swift b/Frameworks/Feed/Bundle/Sources/Coordinators/FeedItemCoordinator.swift new file mode 100644 index 0000000..765c916 --- /dev/null +++ b/Frameworks/Feed/Bundle/Sources/Coordinators/FeedItemCoordinator.swift @@ -0,0 +1,42 @@ +// +// FeedItemCoordinator.swift +// ReviewsFeed +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import Foundation +import ReviewsCoordinationKit + +public final class FeedItemCoordinator: Coordinator { + + // MARK: Constants + public let router: any Router + + private let item: Review + + // MARK: Properties + public var children: [any Coordinator] = [] + + public init( + item: Review, + router: any Router + ) { + self.item = item + self.router = router + } + + // MARK: Functions + public func present( + animated: Bool, + onDismiss: Router.OnDismissClosure? = nil + ) { + router.present( + FeedItemViewController(item), + animated: animated, + onDismiss: onDismiss + ) + } + +} diff --git a/Frameworks/Feed/Bundle/Sources/Coordinators/FeedListCoordinator.swift b/Frameworks/Feed/Bundle/Sources/Coordinators/FeedListCoordinator.swift index cf494ab..73a5fca 100644 --- a/Frameworks/Feed/Bundle/Sources/Coordinators/FeedListCoordinator.swift +++ b/Frameworks/Feed/Bundle/Sources/Coordinators/FeedListCoordinator.swift @@ -10,22 +10,29 @@ import Foundation import ReviewsCoordinationKit public final class FeedListCoordinator: Coordinator { - + // MARK: Constants public let router: any Router - private let sessionConfiguration: URLSessionConfiguration + private let configuration: FeedListConfiguration // MARK: Properties public var children: [any Coordinator] = [] + lazy var viewController = { + FeedListViewController(.init( + configuration: configuration, + coordination: self + )) + }() + // MARK: Initialisers public init( - router: any Router, - sessionConfiguration: URLSessionConfiguration = .ephemeral + configuration: FeedListConfiguration, + router: any Router ) { + self.configuration = configuration self.router = router - self.sessionConfiguration = sessionConfiguration } // MARK: Functions @@ -34,12 +41,26 @@ public final class FeedListCoordinator: Coordinator { onDismiss: Router.OnDismissClosure? = nil ) { router.present( - FeedListViewController(configuration: .init( - session: sessionConfiguration - )), + viewController, animated: animated, onDismiss: onDismiss ) } } + +// MARK: - FeedListCoordination +extension FeedListCoordinator: FeedListCoordination { + + // MARK: Functions + func open(_ item: Review) { + present( + child: FeedItemCoordinator( + item: item, + router: SheetRouter(parentViewController: viewController) + ), + animated: true + ) + } + +} diff --git a/Frameworks/Feed/Bundle/Sources/Logic/Models/Review.swift b/Frameworks/Feed/Bundle/Sources/Logic/Models/Review.swift index a315a88..4da44cf 100644 --- a/Frameworks/Feed/Bundle/Sources/Logic/Models/Review.swift +++ b/Frameworks/Feed/Bundle/Sources/Logic/Models/Review.swift @@ -8,7 +8,7 @@ import Foundation -struct Review { +public struct Review { // MARK: Constants let author: String diff --git a/Frameworks/Feed/Bundle/Sources/Logic/Structs/FeedListConfiguration.swift b/Frameworks/Feed/Bundle/Sources/Logic/Structs/FeedListConfiguration.swift new file mode 100644 index 0000000..a38d262 --- /dev/null +++ b/Frameworks/Feed/Bundle/Sources/Logic/Structs/FeedListConfiguration.swift @@ -0,0 +1,29 @@ +// +// FeedListConfiguration.swift +// ReviewsFeed +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import Foundation + +public struct FeedListConfiguration { + + // MARK: Constants + let appID: String + let countryCode: String + let session: URLSessionConfiguration + + // MARK: Initialisers + public init( + appID: String = "474495017", + countryCode: String = "nl", + session: URLSessionConfiguration = .ephemeral + ) { + self.appID = appID + self.countryCode = countryCode + self.session = session + } + +} diff --git a/Frameworks/Feed/Bundle/Sources/Logic/View Models/FeedListViewModel.swift b/Frameworks/Feed/Bundle/Sources/Logic/View Models/FeedListViewModel.swift index 5fd5a99..08671b7 100644 --- a/Frameworks/Feed/Bundle/Sources/Logic/View Models/FeedListViewModel.swift +++ b/Frameworks/Feed/Bundle/Sources/Logic/View Models/FeedListViewModel.swift @@ -13,11 +13,8 @@ import ReviewsiTunesKit extension FeedListViewController { final class ViewModel: ObservableObject { - // MARK: Type aliases - typealias Configuration = FeedListViewController.Configuration - // MARK: Constants - private let configuration: Configuration + private let configuration: FeedListConfiguration private let filterWords: FilterWordsUseCase = .init() private let topWords: TopWordsUseCase = .init() @@ -30,18 +27,24 @@ extension FeedListViewController { var items: [Review] = [] var words: [TopWord] = [] - + private var reviewsAll: [Review] = [] private var reviewsFiltered: FilteredReviews = [:] private var reviewsTopWords: TopWordsReviews = [:] + private weak var coordination: FeedListCoordination? + lazy private var iTunesService: iTunesService = { .init(configuration: .init(session: configuration.session)) }() // MARK: Initialisers - init(configuration: Configuration = .init()) { + init( + configuration: FeedListConfiguration = .init(), + coordination: FeedListCoordination? = nil + ) { self.configuration = configuration + self.coordination = coordination } // MARK: Computed @@ -114,6 +117,12 @@ extension FeedListViewController { ? items[index - 1] : items[index] } + + func openItem(at index: Int) { + guard let item = item(for: index) else { return } + + coordination?.open(item) + } } } diff --git a/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift b/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift index 78cfe49..4fee1d3 100644 --- a/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift +++ b/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift @@ -13,7 +13,7 @@ import ReviewsUIKit import SwiftUI import UIKit -public class FeedListViewController: UITableViewController { +final class FeedListViewController: UITableViewController { // MARK: Constants private let viewModel: ViewModel @@ -79,8 +79,8 @@ public class FeedListViewController: UITableViewController { }() // MARK: Initialisers - public init(configuration: Configuration = .init()) { - self.viewModel = .init(configuration: configuration) + init(_ viewModel: ViewModel) { + self.viewModel = viewModel super.init(style: .plain) } @@ -95,7 +95,7 @@ public class FeedListViewController: UITableViewController { } // MARK: UIViewController - public override func viewDidLoad() { + override func viewDidLoad() { super.viewDidLoad() setNavigationBar() @@ -108,14 +108,14 @@ public class FeedListViewController: UITableViewController { } // MARK: UITableViewDataSource - public override func tableView( + override func tableView( _ tableView: UITableView, numberOfRowsInSection section: Int ) -> Int { viewModel.itemsCount } - public override func tableView( + override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { @@ -127,21 +127,16 @@ public class FeedListViewController: UITableViewController { } // MARK: UITableViewDelegate - public override func tableView( + override func tableView( _ tableView: UITableView, didSelectRowAt indexPath: IndexPath ) { - guard let item = viewModel.item(for: indexPath.row) else { return } - + viewModel.openItem(at: indexPath.row) + tableView.deselectRow( at: indexPath, animated: true ) - - navigationController?.pushViewController( - FeedItemViewController(item), - animated: true - ) } } @@ -292,29 +287,6 @@ private extension FeedListViewController { } -// MARK: - Configuration -extension FeedListViewController { - public struct Configuration { - - // MARK: Constants - let appID: String - let countryCode: String - let session: URLSessionConfiguration - - // MARK: Initialisers - public init( - appID: String = "474495017", - countryCode: String = "nl", - session: URLSessionConfiguration = .ephemeral - ) { - self.appID = appID - self.countryCode = countryCode - self.session = session - } - - } -} - // MARK: - String+Constants private extension String { enum Cell { @@ -363,9 +335,9 @@ import ReviewsiTunesKit #Preview("Feed List loading reviews") { MockURLProtocol.response = .init(statusCode: 200) - return UINavigationController( - rootViewController: FeedListViewController(configuration: .init(session: .mock)) - ) + return UINavigationController(rootViewController: FeedListViewController(.init( + configuration: .init(session: .mock) + ))) } @available(iOS 17.0, *) @@ -421,9 +393,9 @@ import ReviewsiTunesKit ]) ) - return UINavigationController( - rootViewController: FeedListViewController(configuration: .init(session: .mock)) - ) + return UINavigationController(rootViewController: FeedListViewController(.init( + configuration: .init(session: .mock) + ))) } @available(iOS 17.0, *) @@ -433,13 +405,13 @@ import ReviewsiTunesKit object: Feed(entries: []) ) - return UINavigationController( - rootViewController: FeedListViewController(configuration: .init(session: .mock)) - ) + return UINavigationController(rootViewController: FeedListViewController(.init( + configuration: .init(session: .mock) + ))) } @available(iOS 17.0, *) #Preview("Feed List with live reviews") { - UINavigationController(rootViewController: FeedListViewController()) + return UINavigationController(rootViewController: FeedListViewController(.init())) } #endif diff --git a/Libraries/Coordination/Kit/Sources/Routers/SheetRouter.swift b/Libraries/Coordination/Kit/Sources/Routers/SheetRouter.swift new file mode 100644 index 0000000..a748efb --- /dev/null +++ b/Libraries/Coordination/Kit/Sources/Routers/SheetRouter.swift @@ -0,0 +1,103 @@ +// +// SheetRouter.swift +// ReviewsCoordinationKit +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import ReviewsUIKit +import UIKit + +public class SheetRouter: BaseNavigationRouter { + + // MARK: Properties + public unowned let parentViewController: UIViewController + + // MARK: Initialisers + public init(parentViewController: UIViewController) { + self.parentViewController = parentViewController + + super.init(navigationController: .init()) + } + +} + +// MARK: - Router +extension SheetRouter: Router { + + // MARK: Functions + public func present( + _ viewController: UIViewController, + animated: Bool, + onDismiss: Router.OnDismissClosure? + ) { + onDismissForViewController[viewController] = onDismiss + + if navigationController.viewControllers.isEmpty { + presentModally( + viewController, + animated: animated + ) + } else { + navigationController.pushViewController( + viewController, + animated: animated + ) + } + } + + public func dismiss(animated: Bool) { + guard let firstViewController = navigationController.viewControllers.first else { + return + } + + performOnDismiss(for: firstViewController) + + parentViewController.dismiss(animated: animated) + } + +} + +// MARK: - Helpers +private extension SheetRouter { + + // MARK: Actions + @objc func onCancelPressed() { + guard let firstViewController = navigationController.viewControllers.first else { + return + } + + dismiss(animated: true) + + performOnDismiss(for: firstViewController) + } + + // MARK: Functions + func presentModally( + _ viewController: UIViewController, + animated: Bool + ) { + viewController.navigationItem.rightBarButtonItem = UIBarButtonItem( + image: .Icon.close, + style: .plain, + target: self, + action: #selector(onCancelPressed) + ) + + if #available(iOS 15.0, *) { + navigationController.sheetPresentationController?.detents = [.medium(), .large()] + } + + navigationController.setViewControllers( + [viewController], + animated: false + ) + + parentViewController.present( + navigationController, + animated: animated + ) + } + +} diff --git a/Libraries/Package.swift b/Libraries/Package.swift index e0327a1..09d51be 100644 --- a/Libraries/Package.swift +++ b/Libraries/Package.swift @@ -25,6 +25,7 @@ let package = Package( name: .Target.coordination.kit, dependencies: [ .byName(name: .Target.foundation.kit), + .byName(name: .Target.ui.kit), ], path: "Coordination/Kit" ), diff --git a/Libraries/UI/Kit/Sources/Extensions/String+Icons.swift b/Libraries/UI/Kit/Sources/Extensions/String+Icons.swift index 5ecef45..161b879 100644 --- a/Libraries/UI/Kit/Sources/Extensions/String+Icons.swift +++ b/Libraries/UI/Kit/Sources/Extensions/String+Icons.swift @@ -19,6 +19,7 @@ public extension String { public static let star4 = "4.circle" public static let star5 = "5.circle" + static let close = "xmark.circle.fill" static let filter = "camera.filters" static let questionMark = "questionmark.circle.fill" static let star = "star" diff --git a/Libraries/UI/Kit/Sources/Extensions/UIImage+ICons.swift b/Libraries/UI/Kit/Sources/Extensions/UIImage+Icons.swift similarity index 80% rename from Libraries/UI/Kit/Sources/Extensions/UIImage+ICons.swift rename to Libraries/UI/Kit/Sources/Extensions/UIImage+Icons.swift index ee2789d..049dfbf 100644 --- a/Libraries/UI/Kit/Sources/Extensions/UIImage+ICons.swift +++ b/Libraries/UI/Kit/Sources/Extensions/UIImage+Icons.swift @@ -1,5 +1,5 @@ // -// UIImage+ICons.swift +// UIImage+Icons.swift // ReviewsUIKit // // Created by Javier Cicchelli on 20/03/2024. @@ -12,6 +12,7 @@ public extension UIImage { enum Icon { // MARK: Constants + public static let close = UIImage(systemName: .Icon.close) public static let filter = UIImage(systemName: .Icon.filter) public static let star = UIImage(systemName: .Icon.star) diff --git a/Reviews.xcodeproj/project.pbxproj b/Reviews.xcodeproj/project.pbxproj index 3e3f750..d05c3f5 100644 --- a/Reviews.xcodeproj/project.pbxproj +++ b/Reviews.xcodeproj/project.pbxproj @@ -10,6 +10,9 @@ 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 */; }; + 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 */; }; 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 */; }; @@ -62,6 +65,9 @@ 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 = ""; }; + 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 = ""; }; 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 = ""; }; @@ -134,6 +140,7 @@ 02909E772BAB6AD500710E14 /* Enumerations */, 023AC7FA2BAA3EB60027D064 /* Extensions */, 02620B8A2BA89C3300DE7137 /* Models */, + 0281346F2BACC8B00074AB4B /* Structs */, 02620B872BA89C0700DE7137 /* View Models */, ); path = Logic; @@ -174,6 +181,22 @@ path = Models; sourceTree = ""; }; + 0281346F2BACC8B00074AB4B /* Structs */ = { + isa = PBXGroup; + children = ( + 028134702BACC8CC0074AB4B /* FeedListConfiguration.swift */, + ); + path = Structs; + sourceTree = ""; + }; + 028134802BACCC630074AB4B /* Coordination */ = { + isa = PBXGroup; + children = ( + 028134812BACCC770074AB4B /* FeedListCoordination.swift */, + ); + path = Coordination; + sourceTree = ""; + }; 02909E772BAB6AD500710E14 /* Enumerations */ = { isa = PBXGroup; children = ( @@ -202,6 +225,8 @@ 02C1B1952BAC9BE7001781DE /* Coordinators */ = { isa = PBXGroup; children = ( + 028134802BACCC630074AB4B /* Coordination */, + 028134832BACD0B20074AB4B /* FeedItemCoordinator.swift */, 02C1B1962BAC9BFE001781DE /* FeedListCoordinator.swift */, ); path = Coordinators; @@ -473,6 +498,7 @@ 02DC7FAC2BA51B4C000EEEBE /* FeedItemViewController.swift in Sources */, 02909E7B2BAB6D2E00710E14 /* Bundle+Constants.swift in Sources */, 02EACF2E2BABA34600FF8ECD /* FeedItemCell.swift in Sources */, + 028134842BACD0B20074AB4B /* FeedItemCoordinator.swift in Sources */, 02909E7D2BAB7FFE00710E14 /* Review+DTOs.swift in Sources */, 0220ADA32BA90646001E6A9F /* FeedItemView.swift in Sources */, 02EACF362BABB2F200FF8ECD /* TopWord+DTOs.swift in Sources */, @@ -480,6 +506,8 @@ 02C1B1972BAC9BFE001781DE /* FeedListCoordinator.swift in Sources */, 02DC7FAE2BA51B4C000EEEBE /* FeedListViewController.swift in Sources */, 02EACF322BABB23A00FF8ECD /* TopWordsView.swift in Sources */, + 028134712BACC8CC0074AB4B /* FeedListConfiguration.swift in Sources */, + 028134822BACCC780074AB4B /* FeedListCoordination.swift in Sources */, 02909E792BAB6B0200710E14 /* FilterOption.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0;