diff --git a/swift-evolution.xcodeproj/project.pbxproj b/swift-evolution.xcodeproj/project.pbxproj index 44aca98..7625c93 100644 --- a/swift-evolution.xcodeproj/project.pbxproj +++ b/swift-evolution.xcodeproj/project.pbxproj @@ -62,6 +62,11 @@ 915265821E67D7A900C2AFDA /* ProposalTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 915265801E67D7A900C2AFDA /* ProposalTableViewCell.swift */; }; 915265831E67D7A900C2AFDA /* ProposalTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 915265811E67D7A900C2AFDA /* ProposalTableViewCell.xib */; }; 9152658A1E67E01800C2AFDA /* ListProposalsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 915265891E67E01800C2AFDA /* ListProposalsViewController.swift */; }; + E7F10001A1B2C3D4E5F60002 /* ProposalListFiltering.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F10001A1B2C3D4E5F60001 /* ProposalListFiltering.swift */; }; + E7F10001A1B2C3D4E5F60004 /* ProposalPeopleBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F10001A1B2C3D4E5F60003 /* ProposalPeopleBuilder.swift */; }; + E7F10001A1B2C3D4E5F60006 /* ProposalListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F10001A1B2C3D4E5F60005 /* ProposalListViewModel.swift */; }; + E7F10001A1B2C3D4E5F60008 /* ProposalsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F10001A1B2C3D4E5F60007 /* ProposalsCoordinator.swift */; }; + E7F10001A1B2C3D4E5F6000A /* ProposalListFilteringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F10001A1B2C3D4E5F60009 /* ProposalListFilteringTests.swift */; }; 9152658D1E67E12800C2AFDA /* BorderedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9152658C1E67E12800C2AFDA /* BorderedLabel.swift */; }; 9152658F1E67E74700C2AFDA /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9152658E1E67E74700C2AFDA /* UIColor.swift */; }; 915A883120A4C97200184F6E /* Section+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 915A883020A4C97200184F6E /* Section+Extension.swift */; }; @@ -188,6 +193,11 @@ 915265801E67D7A900C2AFDA /* ProposalTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProposalTableViewCell.swift; sourceTree = ""; }; 915265811E67D7A900C2AFDA /* ProposalTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ProposalTableViewCell.xib; sourceTree = ""; }; 915265891E67E01800C2AFDA /* ListProposalsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListProposalsViewController.swift; sourceTree = ""; }; + E7F10001A1B2C3D4E5F60001 /* ProposalListFiltering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProposalListFiltering.swift; sourceTree = ""; }; + E7F10001A1B2C3D4E5F60003 /* ProposalPeopleBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProposalPeopleBuilder.swift; sourceTree = ""; }; + E7F10001A1B2C3D4E5F60005 /* ProposalListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProposalListViewModel.swift; sourceTree = ""; }; + E7F10001A1B2C3D4E5F60007 /* ProposalsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProposalsCoordinator.swift; sourceTree = ""; }; + E7F10001A1B2C3D4E5F60009 /* ProposalListFilteringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProposalListFilteringTests.swift; sourceTree = ""; }; 9152658C1E67E12800C2AFDA /* BorderedLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BorderedLabel.swift; sourceTree = ""; }; 9152658E1E67E74700C2AFDA /* UIColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; 915A883020A4C97200184F6E /* Section+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Section+Extension.swift"; sourceTree = ""; }; @@ -550,6 +560,10 @@ children = ( 915265881E67D87E00C2AFDA /* uitableviewcell */, 915265891E67E01800C2AFDA /* ListProposalsViewController.swift */, + E7F10001A1B2C3D4E5F60001 /* ProposalListFiltering.swift */, + E7F10001A1B2C3D4E5F60005 /* ProposalListViewModel.swift */, + E7F10001A1B2C3D4E5F60003 /* ProposalPeopleBuilder.swift */, + E7F10001A1B2C3D4E5F60007 /* ProposalsCoordinator.swift */, ); path = list; sourceTree = ""; @@ -759,6 +773,7 @@ isa = PBXGroup; children = ( A2B7B61B2046F6F20022FA74 /* ProposalDetailViewControllerTests.swift */, + E7F10001A1B2C3D4E5F60009 /* ProposalListFilteringTests.swift */, ); path = controllers; sourceTree = ""; @@ -998,6 +1013,10 @@ 912938D31E766EE000D2B2CF /* Sequence.swift in Sources */, 670F30E01E8667E800A58FB1 /* ProposalListHeaderTableViewCell.swift in Sources */, 9152658A1E67E01800C2AFDA /* ListProposalsViewController.swift in Sources */, + E7F10001A1B2C3D4E5F60002 /* ProposalListFiltering.swift in Sources */, + E7F10001A1B2C3D4E5F60004 /* ProposalPeopleBuilder.swift in Sources */, + E7F10001A1B2C3D4E5F60006 /* ProposalListViewModel.swift in Sources */, + E7F10001A1B2C3D4E5F60008 /* ProposalsCoordinator.swift in Sources */, 91485DED1E6902A2008C4715 /* StatusLabel.swift in Sources */, 91B2460B1E99BC4100904DE3 /* Routes.swift in Sources */, 91356A38203E656C00E5C625 /* URL.swift in Sources */, @@ -1050,6 +1069,7 @@ files = ( 9152653B1E67659A00C2AFDA /* swift_evolutionTests.swift in Sources */, A2B7B61C2046F6F20022FA74 /* ProposalDetailViewControllerTests.swift in Sources */, + E7F10001A1B2C3D4E5F6000A /* ProposalListFilteringTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/swift-evolution/sourcecode/controllers/proposals/list/ListProposalsViewController.swift b/swift-evolution/sourcecode/controllers/proposals/list/ListProposalsViewController.swift index 3737a46..3145d20 100644 --- a/swift-evolution/sourcecode/controllers/proposals/list/ListProposalsViewController.swift +++ b/swift-evolution/sourcecode/controllers/proposals/list/ListProposalsViewController.swift @@ -1,472 +1,264 @@ import UIKit -import SafariServices final class ListProposalsViewController: BaseViewController { - + // Private IBOutlets @IBOutlet private(set) weak var tableView: UITableView! @IBOutlet private(set) weak var footerView: UIView! @IBOutlet private(set) weak var filterHeaderView: FilterHeaderView! @IBOutlet private(set) weak var filterHeaderViewHeightConstraint: NSLayoutConstraint! - + @IBOutlet private(set) weak var settingsBarButtonItem: UIBarButtonItem? - // Private properties - fileprivate var timer: Timer = Timer() - - fileprivate lazy var filteredDataSource: [Proposal] = { - return [] - }() - - fileprivate var dataSource: [Proposal] = { return [] }() { - didSet { - guard - oldValue.isEmpty, - let proposal = dataSource.first, - UIDevice.current.userInterfaceIdiom == .pad - else { return } - DispatchQueue.main.async { self.didSelect(proposal: proposal) } - } - } - - fileprivate weak var appDelegate: AppDelegate? - - // Filters - fileprivate var languages: [Version] = [] - fileprivate var status: [StatusState] = [] - - // Proposal ordering - fileprivate lazy var statusOrder: [StatusState] = [ - .awaitingReview, - .scheduledForReview, - .activeReview, - .accepted, - .acceptedWithRevisions, - .previewing, - .implemented, - .returnedForRevision, - .deferred, - .rejected, - .withdrawn - ] - + private var timer = Timer() + private let viewModel = ProposalListViewModel() + private lazy var coordinator = ProposalsCoordinator( + listViewController: self, + peopleProvider: { [weak self] in self?.viewModel.people ?? [:] }, + proposalProvider: { [weak self] id in self?.viewModel.proposal(id: id) } + ) + // MARK: - Life Cycle + override func viewDidLoad() { super.viewDidLoad() - - self.appDelegate = UIApplication.shared.delegate as? AppDelegate - - // Target to RefreshControl + refreshControl.addTarget(self, action: #selector(pullToRefresh(_:)), for: .valueChanged) - - // Register Cell to TableView + tableView.registerNib(withClass: ProposalTableViewCell.self) tableView.registerNib(withClass: ProposalListHeaderTableViewCell.self) - + tableView.estimatedRowHeight = 164 tableView.estimatedSectionHeaderHeight = 44.0 tableView.rowHeight = UITableView.automaticDimension - + tableView.addSubview(refreshControl) - - // Filter Header View settings + filterHeaderView.statusFilterView.delegate = self filterHeaderView.languageVersionFilterView.delegate = self filterHeaderView.searchBar.delegate = self filterHeaderView.clipsToBounds = true - + filterHeaderView.filterButton.addTarget(self, action: #selector(filterButtonAction(_:)), for: .touchUpInside) filterHeaderView.filteredByButton.addTarget(self, action: #selector(filteredByButtonAction(_:)), for: .touchUpInside) - + filterHeaderView.filterLevel = .without - - // Notifications + + viewModel.onChange = { [weak self] in + self?.reloadFilteredTable() + } + registerNotifications() - - // Request the Proposes + coordinator.bindProfileDismissalToProposalDetail() getProposalList() - - // Configure reachability closures - self.reachability?.whenReachable = { [weak self] reachability in - if self?.dataSource.count == 0 { + + reachability?.whenReachable = { [weak self] _ in + if self?.viewModel.isEmpty == true { self?.getProposalList() } } - - self.reachability?.whenUnreachable = { [weak self] reachability in - if self?.dataSource.count == 0 { + + reachability?.whenUnreachable = { [weak self] _ in + if self?.viewModel.isEmpty == true { self?.showNoConnection = true } } - + if let title = Environment.title, title.isEmpty == false { self.title = title } - - ProfileViewController.dismissCallback = { object in - Config.Segues.proposalDetail.performSegue(in: self.splitViewController, with: object, split: true) - } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - self.navigationController?.navigationBar.topItem?.setRightBarButton(self.settingsBarButtonItem, animated: true) - } - - override func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() - // Dispose of any resources that can be recreated. + navigationController?.navigationBar.topItem?.setRightBarButton(settingsBarButtonItem, animated: true) } - - // MARK: - Layout + override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() - - self.filterHeaderViewHeightConstraint.constant = self.filterHeaderView.heightForView + filterHeaderViewHeightConstraint.constant = filterHeaderView.heightForView } - - fileprivate func layoutFilterHeaderView() { + + private func layoutFilterHeaderView() { UIView.animate(withDuration: 0.25) { self.filterHeaderViewHeightConstraint.constant = self.filterHeaderView.heightForView self.view.layoutIfNeeded() } } - + deinit { - self.removeNotifications() + removeNotifications() } - - // MARK: - Reachability Retry Action + override func retryButtonAction(_ sender: UIButton) { super.retryButtonAction(sender) - getProposalList() } - + // MARK: - Notifications + func registerNotifications() { - NotificationCenter.default.addObserver(self, - selector: #selector(didReceiveNotification(_:)), - name: NSNotification.Name.URLScheme, - object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(didReceiveNotification(_:)), + name: NSNotification.Name.URLScheme, + object: nil + ) } - + func removeNotifications() { NotificationCenter.default.removeObserver(NSNotification.Name.URLScheme) } - + @objc func didReceiveNotification(_ notification: Notification) { guard notification.name == Notification.Name.URLScheme else { return } - - navigate(to: Navigation.shared) + coordinator.handlePendingNavigation() } - + // MARK: - Navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - if segue.destination is ProposalDetailViewController, - let destination = segue.destination as? ProposalDetailViewController { - - var item: Proposal? - if sender == nil, let indexPath = tableView.indexPathForSelectedRow { - item = filteredDataSource[indexPath.row] - } - else if sender != nil, sender is Proposal { - item = sender as? Proposal - } - - if let proposal = item { - destination.proposal = proposal - - if !Navigation.shared.isClear { - Navigation.shared.clear() - } - } - } - else if segue.destination is ProfileViewController, - let destination = segue.destination as? ProfileViewController, - sender != nil, sender is Person, let person = sender as? Person { - - destination.profile = person + if let destination = segue.destination as? ProposalDetailViewController { + let selected = tableView.indexPathForSelectedRow.flatMap { viewModel.proposal(at: $0.row) } + coordinator.prepareProposalDetail(destination: destination, sender: sender, selectedProposal: selected) + } else if let destination = segue.destination as? ProfileViewController { + coordinator.prepareProfile(destination: destination, sender: sender) } } - - func navigate(to navigation: Navigation?) { - guard let navigation = navigation, !navigation.isClear else { - return - } - guard - let host = navigation.host, - let value = navigation.value else { + // MARK: - Requests + + private func getProposalList() { + guard let reachability = reachability, reachability.connection != .none else { + refreshControl.endRefreshing() + showNoConnection = true return } - - let sourceViewController = UIDevice.current.userInterfaceIdiom == .pad ? splitViewController : self - if host == .proposal { - let id: Int = value.regex(Config.Common.Regex.proposalID) - - guard let proposal = dataSource.get(by: id) else { + showNoConnection = false + refreshControl.forceShowAnimation() + + viewModel.loadProposals { [weak self] result in + guard let self = self else { return } - Config.Segues.proposalDetail.performSegue(in: sourceViewController, with: proposal, split: true) - } - else if host == .profile { - guard let appDelegate = self.appDelegate, let person = appDelegate.people.get(username: value) else { - return + if self.refreshControl.isRefreshing { + self.refreshControl.endRefreshing() } - Config.Segues.profile.performSegue(in: self, with: person, formSheet: true) - } - } - - // MARK: - Requests - fileprivate func getProposalList() { - if let reachability = self.reachability, reachability.connection != .none { - // Hide No Connection View - showNoConnection = false - refreshControl.forceShowAnimation() - - EvolutionService.listProposals { [weak self] result in - guard let self = self else { - return - } - - if self.dataSource.count > 0 { - self.filteredDataSource = [] - self.dataSource = [] - self.languages = [] - self.status = [] - self.appDelegate?.people = [:] - } - - DispatchQueue.main.async { - if self.refreshControl.isRefreshing { - self.refreshControl.endRefreshing() - } - } + switch result { + case .failure(let error): + print("Error: \(error)") + + case .success: + self.filterHeaderView?.statusSource = self.viewModel.statusOrderSource + self.filterHeaderView?.languageVersionSource = self.viewModel.languageVersions + self.syncPeopleCache() + self.tableView.reloadData() - guard let proposals = result.value else { - if let error = result.error { - print("Error: \(error)") - } - return + if UIDevice.current.userInterfaceIdiom == .pad, + let first = self.viewModel.proposals.first { + self.coordinator.showProposalDetail(first) } - - self.dataSource = proposals.filter(by: self.statusOrder) - self.filteredDataSource = self.dataSource - self.filterHeaderView?.statusSource = self.statusOrder - self.buildPeople() - - // Language Versions source - self.filterHeaderView?.languageVersionSource = proposals.compactMap({ $0.status.version }).removeDuplicates().sorted() - - DispatchQueue.main.async { - self.tableView.reloadData() - - if self.refreshControl.isRefreshing { - self.refreshControl.endRefreshing() - } - - // In case of user have come - if !Navigation.shared.isClear { - self.navigate(to: Navigation.shared) - } + + if !Navigation.shared.isClear { + self.coordinator.handlePendingNavigation() } } } - else { - refreshControl.endRefreshing() - showNoConnection = true - } } - + + private func syncPeopleCache() { + (UIApplication.shared.delegate as? AppDelegate)?.people = viewModel.people + } + // MARK: - Actions + @objc func filterButtonAction(_ sender: UIButton?) { guard let sender = sender else { return } - + sender.isSelected = !sender.isSelected - self.filterHeaderView.filterLevel = .without - + filterHeaderView.filterLevel = .without + if !sender.isSelected { - self.filterHeaderView.filteredByButton.isSelected = false - } - else { - // Open filter until filteredByButton max height - self.filterHeaderView.filterLevel = .filtered - - // If have any status selected, open to status list max height, else open to language version max height - if let selected = self.filterHeaderView.statusFilterView.indexPathsForSelectedItems, selected.count > 0 { - - self.filterHeaderView.filterLevel = .status - if self.selected(status: .implemented) { - self.filterHeaderView.filterLevel = .version + filterHeaderView.filteredByButton.isSelected = false + viewModel.setFiltersActive(false) + } else { + filterHeaderView.filterLevel = .filtered + viewModel.setFiltersActive(true) + + if let selected = filterHeaderView.statusFilterView.indexPathsForSelectedItems, selected.count > 0 { + filterHeaderView.filterLevel = .status + if selected(status: .implemented) { + filterHeaderView.filterLevel = .version } - - self.filterHeaderView.filteredByButton.isSelected = true + filterHeaderView.filteredByButton.isSelected = true } } - - self.updateTableView() - self.layoutFilterHeaderView() + + layoutFilterHeaderView() } - + @objc func filteredByButtonAction(_ sender: UIButton?) { - guard let sender = sender else { return } - - sender.isSelected = !sender.isSelected - self.filterHeaderView.filterLevel = sender.isSelected ? .status : .filtered - - // If have any status selected, open to status list max height, else open to language version max height - if let selected = self.filterHeaderView.statusFilterView.indexPathsForSelectedItems, selected.count > 0, sender.isSelected { - self.filterHeaderView.filterLevel = self.selected(status: .implemented) ? .version : .status - } - - self.layoutFilterHeaderView() - } - - @objc func fireSearch(_ timer: Timer) { - guard let search = timer.userInfo as? Search else { + guard let sender = sender else { return } - - let filtered = self.dataSource.filter(by: search.query) - self.updateTableView(filtered) - } - - @objc private func pullToRefresh(_ sender: UIRefreshControl) { - getProposalList() - } - - // MARK: - Filters - - fileprivate func selected(status: StatusState) -> Bool { - guard let indexPaths = self.filterHeaderView.statusFilterView.indexPathsForSelectedItems, - indexPaths.compactMap({ self.filterHeaderView.statusSource[$0.item] }).filter({ $0 == status }).count > 0 else { - return false + + sender.isSelected = !sender.isSelected + filterHeaderView.filterLevel = sender.isSelected ? .status : .filtered + + if let selected = filterHeaderView.statusFilterView.indexPathsForSelectedItems, + selected.count > 0, + sender.isSelected { + filterHeaderView.filterLevel = selected(status: .implemented) ? .version : .status } - return true + + layoutFilterHeaderView() } - - // MARK: - Utils - - fileprivate func updateTableView(_ filtered: [Proposal]? = nil) { - if let filtered = filtered { - self.filteredDataSource = filtered - } - else { - self.filteredDataSource = self.dataSource - } - - if self.filterHeaderView.filterButton.isSelected { - - // Check if there is at least on status selected - if self.status.count > 0 { - var expection: [StatusState] = [.implemented] - if self.selected(status: .implemented) && self.languages.count == 0 { - expection = [] - } - - self.filteredDataSource = self.filteredDataSource.filter(by: self.status, exceptions: expection).sort(.descending) - } - - // Check if the status selected is equal to .implemented and has language versions selected - if self.selected(status: .implemented) && self.languages.count > 0 { - let implemented = self.dataSource.filter(by: self.languages).filter(status: .implemented) - self.filteredDataSource.append(contentsOf: implemented) - } - } - - // Sort in the right order - self.filteredDataSource = self.filteredDataSource.distinct().filter(by: self.statusOrder) - - self.tableView.beginUpdates() - self.tableView.reloadSections(IndexSet(integer: 0), with: .fade) - self.tableView.endUpdates() - } - - // MARK: - Data Consolidation - - func buildPeople() { - var people: [String: Person] = [:] - - var proposalsPeople = People() - - dataSource.forEach { proposal in - proposal.authors?.forEach { author in - proposalsPeople.append(author) - } - - if let reviewManager = proposal.reviewManager { - proposalsPeople.append(reviewManager) - } - } - - proposalsPeople.forEach { person in - - guard let name = person.name, name.isEmpty == false else { - return - } - - guard people[name] == nil else { - return - } - - people[name] = person - - guard var user = people[name] else { - return - } - - user.id = UUID().uuidString - user.asAuthor = dataSource.filter(author: user) - user.asManager = dataSource.filter(manager: user) - - people[name] = user - } - appDelegate?.people = people + @objc private func pullToRefresh(_ sender: UIRefreshControl) { + getProposalList() } - // MARK: - IBActions @IBAction private func openSettings() { - openViewController(of: "SettingsStoryboardID") + coordinator.showSettings() } - private func openViewController(of storyboardID: String) { - let storyboard = UIStoryboard(name: "Main", bundle: nil) - let controller = storyboard.instantiateViewController(withIdentifier: storyboardID) - - if UIDevice.current.userInterfaceIdiom == .pad { - let navigationController = UINavigationController(rootViewController: controller) - navigationController.modalPresentationStyle = .formSheet - present(navigationController, animated: true) - } + // MARK: - Filters UI Helpers + + private func selected(status: StatusState) -> Bool { + guard + let indexPaths = filterHeaderView.statusFilterView.indexPathsForSelectedItems, + indexPaths.compactMap({ filterHeaderView.statusSource[$0.item] }).contains(status) else { - navigationController?.pushViewController(controller, animated: true) + return false } + return true } -} + private func reloadFilteredTable() { + tableView.beginUpdates() + tableView.reloadSections(IndexSet(integer: 0), with: .fade) + tableView.endUpdates() + } +} // MARK: - UITableView DataSource extension ListProposalsViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return self.filteredDataSource.count + viewModel.filteredProposals.count } - + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.cell(forRowAt: indexPath) as ProposalTableViewCell - cell.delegate = self - cell.proposal = filteredDataSource[indexPath.row] - + cell.proposal = viewModel.proposal(at: indexPath.row) return cell } } @@ -475,19 +267,17 @@ extension ListProposalsViewController: UITableViewDataSource { extension ListProposalsViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let sourceViewController = UIDevice.current.userInterfaceIdiom == .pad ? splitViewController : self - Config.Segues.proposalDetail.performSegue(in: sourceViewController, split: true) + coordinator.showProposalDetail() } - + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let headerCell: ProposalListHeaderTableViewCell = tableView.cell() - headerCell.proposalCount = filteredDataSource.count - + headerCell.proposalCount = viewModel.filteredProposals.count return headerCell.contentView } - + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return 0.01 + 0.01 } } @@ -497,62 +287,53 @@ extension ListProposalsViewController: FilterGenericViewDelegate { func didSelectFilter(_ view: FilterListGenericView, type: FilterListGenericType, indexPath: IndexPath) { switch type { case .status: - if self.filterHeaderView.statusSource[indexPath.item] == .implemented { - self.filterHeaderView.filterLevel = .version - self.layoutFilterHeaderView() - - self.languages = [] + if filterHeaderView.statusSource[indexPath.item] == .implemented { + filterHeaderView.filterLevel = .version + layoutFilterHeaderView() } - - if let item: StatusState = view.dataSource[indexPath.item] as? StatusState { - self.status.append(item) + + if let item = view.dataSource[indexPath.item] as? StatusState { + viewModel.selectStatus(item) } - - self.updateTableView() - + case .version: if let version = view.dataSource[indexPath.item] as? String { - self.languages.append(version) + viewModel.selectLanguage(version) } - - self.updateTableView() - + default: break } - self.filterHeaderView.updateFilterButton(status: self.status) + + filterHeaderView.updateFilterButton(status: viewModel.selectedStatuses) } - + func didDeselectFilter(_ view: FilterListGenericView, type: FilterListGenericType, indexPath: IndexPath) { let item = view.dataSource[indexPath.item] - + switch type { case .status: if let indexPaths = view.indexPathsForSelectedItems, - indexPaths.compactMap({ self.filterHeaderView.statusSource[$0.item] }).filter({ $0 == .implemented }).count == 0 { - - self.filterHeaderView.filterLevel = .status - self.layoutFilterHeaderView() + indexPaths.compactMap({ filterHeaderView.statusSource[$0.item] }).contains(.implemented) == false { + filterHeaderView.filterLevel = .status + layoutFilterHeaderView() } - - - if let status = item as? StatusState, self.status.remove(status) { - self.updateTableView() + + if let status = item as? StatusState { + viewModel.deselectStatus(status) } - + case .version: - if self.languages.remove(string: item.description) { - self.updateTableView() - } - + viewModel.deselectLanguage(item.description) + default: break } - self.filterHeaderView.updateFilterButton(status: self.status) + + filterHeaderView.updateFilterButton(status: viewModel.selectedStatuses) } } - // MARK: - Proposal Delegate extension ListProposalsViewController: ProposalDelegate { @@ -560,75 +341,58 @@ extension ListProposalsViewController: ProposalDelegate { guard let name = person.name else { return } - - let profile = self.appDelegate?.people[name] - Config.Segues.profile.performSegue(in: self, with: profile, formSheet: true) + coordinator.showProfile(viewModel.person(named: name)) } - + func didSelect(proposal: Proposal) { - guard let proposal = self.dataSource.get(by: proposal.id) else { + guard let proposal = viewModel.proposal(id: proposal.id) else { return } - - let sourceViewController = UIDevice.current.userInterfaceIdiom == .pad ? splitViewController : self - Config.Segues.proposalDetail.performSegue(in: sourceViewController, with: proposal, split: true) + coordinator.showProposalDetail(proposal) } - + func didSelect(implementation: Implementation) { - if let url = URL(string: "\(Config.Base.URL.GitHub.base)/\(implementation.path)") { - let safariViewController = SFSafariViewController(url: url) - self.present(safariViewController, animated: true) - } + coordinator.showImplementation(implementation) } } - // MARK: - UISearchBar Delegate -struct Search { - let query: String -} - extension ListProposalsViewController: UISearchBarDelegate { func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { searchBar.setShowsCancelButton(true, animated: true) } - + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { guard searchText != "" else { - self.updateTableView() + viewModel.clearSearch() return } - - if self.timer.isValid { - self.timer.invalidate() + + if timer.isValid { + timer.invalidate() } - - if searchText.count > 3 { - let interval = 0.7 - self.timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in - let filtered = self.dataSource.filter(by: searchText) - self.updateTableView(filtered) + + if ProposalListFiltering.shouldSearchWhileTyping(searchText) { + timer = Timer.scheduledTimer(withTimeInterval: 0.7, repeats: false) { [weak self] _ in + self?.viewModel.applySearch(searchText) } } } - + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { guard let query = searchBar.text, - query.trimmingCharacters(in: .whitespaces) != "" - else { - return + query.trimmingCharacters(in: .whitespaces) != "" + else { + return } - - let filtered = self.dataSource.filter(by: query) - self.updateTableView(filtered) + viewModel.applySearch(query) } - + func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { searchBar.setShowsCancelButton(false, animated: true) searchBar.resignFirstResponder() searchBar.text = "" - - self.updateTableView() + viewModel.clearSearch() } } diff --git a/swift-evolution/sourcecode/controllers/proposals/list/ProposalListFiltering.swift b/swift-evolution/sourcecode/controllers/proposals/list/ProposalListFiltering.swift new file mode 100644 index 0000000..960c5b6 --- /dev/null +++ b/swift-evolution/sourcecode/controllers/proposals/list/ProposalListFiltering.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Pure filter and search helpers for the proposals list. +enum ProposalListFiltering { + + static let defaultStatusOrder: [StatusState] = [ + .awaitingReview, + .scheduledForReview, + .activeReview, + .accepted, + .acceptedWithRevisions, + .previewing, + .implemented, + .returnedForRevision, + .deferred, + .rejected, + .withdrawn + ] + + /// Applies status/language filters and optional search baseline, then orders results. + static func apply( + to proposals: [Proposal], + searchBaseline: [Proposal]? = nil, + selectedStatuses: [StatusState], + selectedLanguages: [Version], + filtersActive: Bool, + implementedSelected: Bool, + statusOrder: [StatusState] = defaultStatusOrder + ) -> [Proposal] { + var filtered = searchBaseline ?? proposals + + if filtersActive { + if !selectedStatuses.isEmpty { + var exceptions: [StatusState] = [.implemented] + if implementedSelected && selectedLanguages.isEmpty { + exceptions = [] + } + + filtered = filtered.filter(by: selectedStatuses, exceptions: exceptions).sort(.descending) + } + + if implementedSelected && !selectedLanguages.isEmpty { + let implemented = proposals.filter(by: selectedLanguages).filter(status: .implemented) + filtered.append(contentsOf: implemented) + } + } + + return filtered.distinct().filter(by: statusOrder) + } + + /// Text proposals by free-form query (id, title, author, etc.). + static func search(_ proposals: [Proposal], query: String) -> [Proposal] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return proposals + } + return proposals.filter(by: trimmed) + } + + /// Whether search should run automatically as the user types. + static func shouldSearchWhileTyping(_ query: String, minimumCharacters: Int = 4) -> Bool { + query.count > (minimumCharacters - 1) + } + + static func languageVersions(from proposals: [Proposal]) -> [Version] { + proposals.compactMap { $0.status.version }.removeDuplicates().sorted() + } + + static func ordered(_ proposals: [Proposal], by statusOrder: [StatusState] = defaultStatusOrder) -> [Proposal] { + proposals.filter(by: statusOrder) + } +} diff --git a/swift-evolution/sourcecode/controllers/proposals/list/ProposalListViewModel.swift b/swift-evolution/sourcecode/controllers/proposals/list/ProposalListViewModel.swift new file mode 100644 index 0000000..b55d5d1 --- /dev/null +++ b/swift-evolution/sourcecode/controllers/proposals/list/ProposalListViewModel.swift @@ -0,0 +1,166 @@ +import Foundation + +protocol ProposalListFetching { + func listProposals(completion: @escaping (ServiceResult<[Proposal]>) -> Void) +} + +struct EvolutionProposalFetcher: ProposalListFetching { + func listProposals(completion: @escaping (ServiceResult<[Proposal]>) -> Void) { + EvolutionService.listProposals(completion: completion) + } +} + +/// Store for proposal list data, filters, search, and people cache. +final class ProposalListViewModel { + + typealias OnChange = () -> Void + + private let fetcher: ProposalListFetching + private let statusOrder: [StatusState] + + private(set) var proposals: [Proposal] = [] + private(set) var filteredProposals: [Proposal] = [] + private(set) var people: [String: Person] = [:] + private(set) var languageVersions: [Version] = [] + + private(set) var selectedStatuses: [StatusState] = [] + private(set) var selectedLanguages: [Version] = [] + private(set) var filtersActive = false + private(set) var searchQuery: String = "" + + var onChange: OnChange? + + var statusOrderSource: [StatusState] { + statusOrder + } + + var isEmpty: Bool { + proposals.isEmpty + } + + init( + fetcher: ProposalListFetching = EvolutionProposalFetcher(), + statusOrder: [StatusState] = ProposalListFiltering.defaultStatusOrder + ) { + self.fetcher = fetcher + self.statusOrder = statusOrder + } + + // MARK: - Loading + + func loadProposals(completion: @escaping (Result<[Proposal], Error>) -> Void) { + fetcher.listProposals { [weak self] result in + guard let self = self else { + return + } + + switch result { + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + + case .success(let proposals): + DispatchQueue.main.async { + self.replaceProposals(proposals) + completion(.success(self.proposals)) + } + } + } + } + + func replaceProposals(_ proposals: [Proposal]) { + self.proposals = ProposalListFiltering.ordered(proposals, by: statusOrder) + people = ProposalPeopleBuilder.build(from: self.proposals) + languageVersions = ProposalListFiltering.languageVersions(from: proposals) + selectedStatuses = [] + selectedLanguages = [] + searchQuery = "" + refreshFilteredProposals() + } + + // MARK: - Filters + + func setFiltersActive(_ active: Bool) { + filtersActive = active + refreshFilteredProposals() + } + + func selectStatus(_ status: StatusState) { + selectedStatuses.append(status) + if status == .implemented { + selectedLanguages = [] + } + refreshFilteredProposals() + } + + func deselectStatus(_ status: StatusState) { + _ = selectedStatuses.remove(status) + refreshFilteredProposals() + } + + func selectLanguage(_ version: Version) { + selectedLanguages.append(version) + refreshFilteredProposals() + } + + func deselectLanguage(_ version: Version) { + _ = selectedLanguages.remove(string: version) + refreshFilteredProposals() + } + + // MARK: - Search + + func applySearch(_ query: String) { + searchQuery = query + refreshFilteredProposals() + } + + func clearSearch() { + searchQuery = "" + refreshFilteredProposals() + } + + // MARK: - Lookups + + func proposal(at index: Int) -> Proposal? { + guard filteredProposals.indices.contains(index) else { + return nil + } + return filteredProposals[index] + } + + func proposal(id: Int) -> Proposal? { + proposals.get(by: id) + } + + func person(named name: String) -> Person? { + people[name] + } + + func person(username: String) -> Person? { + people.get(username: username) + } + + // MARK: - Private + + private func refreshFilteredProposals() { + let baseline: [Proposal]? + if searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + baseline = nil + } else { + baseline = ProposalListFiltering.search(proposals, query: searchQuery) + } + + filteredProposals = ProposalListFiltering.apply( + to: proposals, + searchBaseline: baseline, + selectedStatuses: selectedStatuses, + selectedLanguages: selectedLanguages, + filtersActive: filtersActive, + implementedSelected: selectedStatuses.contains(.implemented), + statusOrder: statusOrder + ) + onChange?() + } +} diff --git a/swift-evolution/sourcecode/controllers/proposals/list/ProposalPeopleBuilder.swift b/swift-evolution/sourcecode/controllers/proposals/list/ProposalPeopleBuilder.swift new file mode 100644 index 0000000..4e7e05e --- /dev/null +++ b/swift-evolution/sourcecode/controllers/proposals/list/ProposalPeopleBuilder.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Builds the people cache used by profile deep links and author/manager taps. +enum ProposalPeopleBuilder { + + static func build(from proposals: [Proposal]) -> [String: Person] { + var people: [String: Person] = [:] + var proposalsPeople = People() + + proposals.forEach { proposal in + proposal.authors?.forEach { author in + proposalsPeople.append(author) + } + + if let reviewManager = proposal.reviewManager { + proposalsPeople.append(reviewManager) + } + } + + proposalsPeople.forEach { person in + guard let name = person.name, name.isEmpty == false else { + return + } + + guard people[name] == nil else { + return + } + + people[name] = person + + guard var user = people[name] else { + return + } + + user.id = UUID().uuidString + user.asAuthor = proposals.filter(author: user) + user.asManager = proposals.filter(manager: user) + + people[name] = user + } + + return people + } +} diff --git a/swift-evolution/sourcecode/controllers/proposals/list/ProposalsCoordinator.swift b/swift-evolution/sourcecode/controllers/proposals/list/ProposalsCoordinator.swift new file mode 100644 index 0000000..0a48909 --- /dev/null +++ b/swift-evolution/sourcecode/controllers/proposals/list/ProposalsCoordinator.swift @@ -0,0 +1,127 @@ +import SafariServices +import UIKit + +/// Coordinates navigation from the proposals list using existing Routes / Navigation / segues. +final class ProposalsCoordinator { + + weak var listViewController: UIViewController? + private let peopleProvider: () -> [String: Person] + private let proposalProvider: (Int) -> Proposal? + + init( + listViewController: UIViewController? = nil, + peopleProvider: @escaping () -> [String: Person], + proposalProvider: @escaping (Int) -> Proposal? + ) { + self.listViewController = listViewController + self.peopleProvider = peopleProvider + self.proposalProvider = proposalProvider + } + + // MARK: - Deep links (Routes → Navigation) + + func handlePendingNavigation(_ navigation: Navigation = .shared) { + guard !navigation.isClear, let host = navigation.host, let value = navigation.value else { + return + } + + switch host { + case .proposal: + let id: Int = value.regex(Config.Common.Regex.proposalID) + guard let proposal = proposalProvider(id) else { + return + } + showProposalDetail(proposal) + + case .profile: + guard let person = peopleProvider().get(username: value) else { + return + } + showProfile(person) + + case .implementation: + break + } + } + + func bindProfileDismissalToProposalDetail() { + ProfileViewController.dismissCallback = { [weak self] object in + guard let proposal = object as? Proposal else { + return + } + self?.showProposalDetail(proposal) + } + } + + // MARK: - Screen presentation + + func showProposalDetail(_ proposal: Proposal? = nil) { + let source = detailPresentationSource + Config.Segues.proposalDetail.performSegue(in: source, with: proposal, split: true) + } + + func showProfile(_ person: Person?) { + Config.Segues.profile.performSegue(in: listViewController, with: person, formSheet: true) + } + + func showSettings() { + openViewController(of: "SettingsStoryboardID") + } + + func showImplementation(_ implementation: Implementation) { + guard let url = URL(string: "\(Config.Base.URL.GitHub.base)/\(implementation.path)") else { + return + } + let safariViewController = SFSafariViewController(url: url) + listViewController?.present(safariViewController, animated: true) + } + + func prepareProposalDetail( + destination: ProposalDetailViewController, + sender: Any?, + selectedProposal: Proposal? + ) { + let item: Proposal? + if let proposal = sender as? Proposal { + item = proposal + } else { + item = selectedProposal + } + + if let proposal = item { + destination.proposal = proposal + if !Navigation.shared.isClear { + Navigation.shared.clear() + } + } + } + + func prepareProfile(destination: ProfileViewController, sender: Any?) { + guard let person = sender as? Person else { + return + } + destination.profile = person + } + + // MARK: - Private + + private var detailPresentationSource: UIViewController? { + if UIDevice.current.userInterfaceIdiom == .pad { + return listViewController?.splitViewController ?? listViewController + } + return listViewController + } + + private func openViewController(of storyboardID: String) { + let storyboard = UIStoryboard(name: "Main", bundle: nil) + let controller = storyboard.instantiateViewController(withIdentifier: storyboardID) + + if UIDevice.current.userInterfaceIdiom == .pad { + let navigationController = UINavigationController(rootViewController: controller) + navigationController.modalPresentationStyle = .formSheet + listViewController?.present(navigationController, animated: true) + } else { + listViewController?.navigationController?.pushViewController(controller, animated: true) + } + } +} diff --git a/swift-evolution/sourcecode/models/Proposal.swift b/swift-evolution/sourcecode/models/Proposal.swift index bb96e02..01016cc 100644 --- a/swift-evolution/sourcecode/models/Proposal.swift +++ b/swift-evolution/sourcecode/models/Proposal.swift @@ -32,19 +32,40 @@ struct Proposal: Decodable { } init(id: Int, link: String) { - self.id = id - self.link = link - self.title = "" - self.status = Status(version: nil, state: .accepted, start: nil, end: nil) - self.summary = nil - self.authors = nil - self.warnings = nil - self.reviewManager = nil - self.sha = nil - self.bugs = nil - self.implementations = nil + self.init( + id: id, + title: "", + status: Status(version: nil, state: .accepted, start: nil, end: nil), + link: link + ) } - + + init( + id: Int, + title: String, + status: Status, + summary: String? = nil, + authors: [Person]? = nil, + warnings: [Warning]? = nil, + link: String? = nil, + reviewManager: Person? = nil, + sha: String? = nil, + bugs: [Bug]? = nil, + implementations: [Implementation]? = nil + ) { + self.id = id + self.title = title + self.status = status + self.summary = summary + self.authors = authors + self.warnings = warnings + self.link = link + self.reviewManager = reviewManager + self.sha = sha + self.bugs = bugs + self.implementations = implementations + } + } extension Proposal { diff --git a/swift-evolutionTests/controllers/ProposalListFilteringTests.swift b/swift-evolutionTests/controllers/ProposalListFilteringTests.swift new file mode 100644 index 0000000..a992fe8 --- /dev/null +++ b/swift-evolutionTests/controllers/ProposalListFilteringTests.swift @@ -0,0 +1,146 @@ +import XCTest +@testable import swift_evolution + +final class ProposalListFilteringTests: XCTestCase { + + private func proposal( + id: Int, + title: String = "Title", + state: StatusState, + version: Version? = nil, + summary: String? = nil, + authors: [Person]? = nil + ) -> Proposal { + Proposal( + id: id, + title: title, + status: Status(version: version, state: state, start: nil, end: nil), + summary: summary, + authors: authors + ) + } + + func testOrderedPreservesStatusPriority() { + let proposals = [ + proposal(id: 3, state: .implemented), + proposal(id: 1, state: .activeReview), + proposal(id: 2, state: .accepted) + ] + + let ordered = ProposalListFiltering.ordered(proposals) + XCTAssertEqual(ordered.map { $0.id }, [1, 2, 3]) + } + + func testSearchMatchesTitleAndId() { + let proposals = [ + proposal(id: 25, title: "Opaque Result Types", state: .accepted), + proposal(id: 10, title: "Other", state: .rejected) + ] + + XCTAssertEqual(ProposalListFiltering.search(proposals, query: "Opaque").map { $0.id }, [25]) + XCTAssertEqual(ProposalListFiltering.search(proposals, query: "25").map { $0.id }, [25]) + XCTAssertEqual(ProposalListFiltering.search(proposals, query: " ").map { $0.id }, [25, 10]) + } + + func testShouldSearchWhileTypingRequiresFourCharacters() { + XCTAssertFalse(ProposalListFiltering.shouldSearchWhileTyping("abc")) + XCTAssertTrue(ProposalListFiltering.shouldSearchWhileTyping("abcd")) + } + + func testApplyFiltersBySelectedStatuses() { + let proposals = [ + proposal(id: 1, state: .accepted), + proposal(id: 2, state: .rejected), + proposal(id: 3, state: .implemented, version: "5.0") + ] + + let filtered = ProposalListFiltering.apply( + to: proposals, + selectedStatuses: [.accepted, .rejected], + selectedLanguages: [], + filtersActive: true, + implementedSelected: false + ) + + XCTAssertEqual(Set(filtered.map { $0.id }), Set([1, 2])) + } + + func testApplyIncludesImplementedVersionsWhenSelected() { + let proposals = [ + proposal(id: 1, state: .accepted), + proposal(id: 2, state: .implemented, version: "5.0"), + proposal(id: 3, state: .implemented, version: "5.1") + ] + + let filtered = ProposalListFiltering.apply( + to: proposals, + selectedStatuses: [.accepted, .implemented], + selectedLanguages: ["5.1"], + filtersActive: true, + implementedSelected: true + ) + + XCTAssertEqual(Set(filtered.map { $0.id }), Set([1, 3])) + } + + func testLanguageVersionsAreUniqueAndSorted() { + let proposals = [ + proposal(id: 1, state: .implemented, version: "5.1"), + proposal(id: 2, state: .implemented, version: "5.0"), + proposal(id: 3, state: .implemented, version: "5.1"), + proposal(id: 4, state: .accepted, version: nil) + ] + + XCTAssertEqual(ProposalListFiltering.languageVersions(from: proposals), ["5.0", "5.1"]) + } + + func testPeopleBuilderIndexesAuthorsAndManagers() { + let author = Person( + id: nil, + name: "Alice", + link: "https://github.com/alice", + username: "alice", + github: nil, + asAuthor: nil, + asManager: nil + ) + let manager = Person( + id: nil, + name: "Bob", + link: "https://github.com/bob", + username: "bob", + github: nil, + asAuthor: nil, + asManager: nil + ) + let proposals = [ + proposal(id: 1, state: .accepted, authors: [author]), + Proposal( + id: 2, + title: "Managed", + status: Status(version: nil, state: .implemented, start: nil, end: nil), + reviewManager: manager + ) + ] + + let people = ProposalPeopleBuilder.build(from: proposals) + XCTAssertEqual(people["Alice"]?.asAuthor?.map { $0.id }, [1]) + XCTAssertEqual(people["Bob"]?.asManager?.map { $0.id }, [2]) + XCTAssertNotNil(people.get(username: "alice")) + } + + func testViewModelAppliesSearchAndFilters() { + let viewModel = ProposalListViewModel() + viewModel.replaceProposals([ + proposal(id: 1, title: "Async Await", state: .accepted), + proposal(id: 2, title: "Actors", state: .rejected), + proposal(id: 3, title: "Async Sequences", state: .accepted) + ]) + + viewModel.setFiltersActive(true) + viewModel.selectStatus(.accepted) + viewModel.applySearch("Async") + + XCTAssertEqual(viewModel.filteredProposals.map { $0.id }, [3, 1]) + } +}