diff --git a/iphone/Maps/Bookmarks/Categories/BMCModels.swift b/iphone/Maps/Bookmarks/Categories/BMCModels.swift index c7d4c934e8..e29ba58cce 100644 --- a/iphone/Maps/Bookmarks/Categories/BMCModels.swift +++ b/iphone/Maps/Bookmarks/Categories/BMCModels.swift @@ -1,6 +1,7 @@ enum BMCSection { case categories case actions + case recentlyDeleted case notifications } @@ -10,6 +11,7 @@ enum BMCAction: BMCModel { case create case exportAll case `import` + case recentlyDeleted(Int) } extension BMCAction { @@ -21,6 +23,8 @@ extension BMCAction { return L("bookmarks_export") case .import: return L("bookmarks_import") + case .recentlyDeleted(let count): + return L("bookmarks_recently_deleted") + " (\(count))" } } @@ -32,6 +36,8 @@ extension BMCAction { return UIImage(named: "ic24PxShare")! case .import: return UIImage(named: "ic24PxImport")! + case .recentlyDeleted: + return UIImage(named: "ic_route_manager_trash_open")! } } } diff --git a/iphone/Maps/Bookmarks/Categories/BMCView/BMCViewController.swift b/iphone/Maps/Bookmarks/Categories/BMCView/BMCViewController.swift index a4bcc969c8..ca435453cc 100644 --- a/iphone/Maps/Bookmarks/Categories/BMCView/BMCViewController.swift +++ b/iphone/Maps/Bookmarks/Categories/BMCView/BMCViewController.swift @@ -40,12 +40,8 @@ final class BMCViewController: MWMViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - viewModel.reloadData() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) viewModel.addToObserverList() + viewModel.reloadData() } override func viewDidDisappear(_ animated: Bool) { @@ -157,13 +153,18 @@ final class BMCViewController: MWMViewController { let deleteAction = UIAlertAction(title: delete, style: .destructive, handler: { [viewModel] _ in viewModel!.deleteCategory(at: index) }) - deleteAction.isEnabled = (viewModel.numberOfRows(section: .categories) > 1) + deleteAction.isEnabled = (viewModel.canDeleteCategory()) actionSheet.addAction(deleteAction) let cancel = L("cancel") actionSheet.addAction(UIAlertAction(title: cancel, style: .cancel, handler: nil)) present(actionSheet, animated: true, completion: nil) } + + private func openRecentlyDeleted() { + let recentlyDeletedController = RecentlyDeletedCategoriesViewController(viewModel: RecentlyDeletedCategoriesViewModel(bookmarksManager: BookmarksManager.shared())) + MapViewController.topViewController().navigationController?.pushViewController(recentlyDeletedController, animated: true) + } } extension BMCViewController: BMCView { @@ -201,7 +202,7 @@ extension BMCViewController: UITableViewDataSource { func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { switch viewModel.sectionType(section: section) { case .categories: fallthrough - case .actions: fallthrough + case .actions, .recentlyDeleted: fallthrough case .notifications: return viewModel.numberOfRows(section: section) } } @@ -217,6 +218,8 @@ extension BMCViewController: UITableViewDataSource { delegate: self) case .actions: return dequeCell(BMCActionsCell.self).config(model: viewModel.action(at: indexPath.row)) + case .recentlyDeleted: + return dequeCell(BMCActionsCell.self).config(model: viewModel.recentlyDeletedCategories()) case .notifications: return dequeCell(BMCNotificationsCell.self) } @@ -229,7 +232,7 @@ extension BMCViewController: UITableViewDelegate { return false } - return viewModel.numberOfRows(section: .categories) > 1 + return viewModel.canDeleteCategory() } func tableView(_ tableView: UITableView, @@ -248,7 +251,7 @@ extension BMCViewController: UITableViewDelegate { switch viewModel.sectionType(section: section) { case .notifications: fallthrough case .categories: return 48 - case .actions: return 24 + case .actions, .recentlyDeleted: return 24 } } @@ -260,7 +263,7 @@ extension BMCViewController: UITableViewDelegate { categoriesHeader.title = L("bookmark_lists") categoriesHeader.delegate = self return categoriesHeader - case .actions: return actionsHeader + case .actions, .recentlyDeleted: return actionsHeader case .notifications: return notificationsHeader } } @@ -275,7 +278,10 @@ extension BMCViewController: UITableViewDelegate { case .create: createNewCategory() case .exportAll: shareAllCategories(anchor: tableView.cellForRow(at: indexPath)) case .import: showImportDialog() + default: + assertionFailure() } + case .recentlyDeleted: openRecentlyDeleted() default: assertionFailure() } diff --git a/iphone/Maps/Bookmarks/Categories/BMCViewModel/BMCDefaultViewModel.swift b/iphone/Maps/Bookmarks/Categories/BMCViewModel/BMCDefaultViewModel.swift index 8097d8cf8a..bc96e12c38 100644 --- a/iphone/Maps/Bookmarks/Categories/BMCViewModel/BMCDefaultViewModel.swift +++ b/iphone/Maps/Bookmarks/Categories/BMCViewModel/BMCDefaultViewModel.swift @@ -27,74 +27,89 @@ final class BMCDefaultViewModel: NSObject { reloadData() } - private func setCategories() { - categories = manager.sortedUserCategories() + private func getCategories() -> [BookmarkGroup] { + manager.sortedUserCategories() } - private func setActions() { - actions = [.create] + private func getActions() -> [BMCAction] { + var actions: [BMCAction] = [.create] actions.append(.import) if !manager.areAllCategoriesEmpty() { actions.append(.exportAll) } + return actions } - private func setNotifications() { - notifications = [.load] + private func getNotifications() -> [BMCNotification] { + [.load] } func reloadData() { - sections = [] + sections.removeAll() if manager.areBookmarksLoaded() { sections.append(.categories) - setCategories() + categories = getCategories() sections.append(.actions) - setActions() + actions = getActions() + + if manager.recentlyDeletedCategoriesCount() != .zero { + sections.append(.recentlyDeleted) + } } else { sections.append(.notifications) - setNotifications() + notifications = getNotifications() } + view?.update(sections: []) } } extension BMCDefaultViewModel { func numberOfSections() -> Int { - return sections.count + sections.count } func sectionType(section: Int) -> BMCSection { - return sections[section] + sections[section] } func sectionIndex(section: BMCSection) -> Int { - return sections.firstIndex(of: section)! + sections.firstIndex(of: section)! } func numberOfRows(section: Int) -> Int { - return numberOfRows(section: sectionType(section: section)) + numberOfRows(section: sectionType(section: section)) } func numberOfRows(section: BMCSection) -> Int { switch section { case .categories: return categories.count case .actions: return actions.count + case .recentlyDeleted: return 1 case .notifications: return notifications.count } } func category(at index: Int) -> BookmarkGroup { - return categories[index] + categories[index] + } + + func canDeleteCategory() -> Bool { + categories.count > 1 } func action(at index: Int) -> BMCAction { - return actions[index] + actions[index] + } + + func recentlyDeletedCategories() -> BMCAction { + .recentlyDeleted(Int(manager.recentlyDeletedCategoriesCount())) } func notification(at index: Int) -> BMCNotification { - return notifications[index] + notifications[index] } func areAllCategoriesHidden() -> Bool { @@ -130,7 +145,7 @@ extension BMCDefaultViewModel { } func checkCategory(name: String) -> Bool { - return manager.checkCategoryName(name) + manager.checkCategoryName(name) } func shareCategoryFile(at index: Int, fileType: KmlFileType, handler: @escaping SharingResultCompletionHandler) { @@ -164,12 +179,11 @@ extension BMCDefaultViewModel { } func areNotificationsEnabled() -> Bool { - return manager.areNotificationsEnabled() + manager.areNotificationsEnabled() } } extension BMCDefaultViewModel: BookmarksObserver { - func onBookmarksLoadFinished() { reloadData() } diff --git a/iphone/Maps/Bookmarks/Categories/RecentlyDeleted/RecentlyDeletedCategoriesViewController.swift b/iphone/Maps/Bookmarks/Categories/RecentlyDeleted/RecentlyDeletedCategoriesViewController.swift new file mode 100644 index 0000000000..c988e70b0e --- /dev/null +++ b/iphone/Maps/Bookmarks/Categories/RecentlyDeleted/RecentlyDeletedCategoriesViewController.swift @@ -0,0 +1,212 @@ +final class RecentlyDeletedCategoriesViewController: MWMViewController { + + private enum LocalizedStrings { + static let clear = L("clear") + static let delete = L("delete") + static let deleteAll = L("delete_all") + static let recover = L("recover") + static let recoverAll = L("recover_all") + static let recentlyDeleted = L("bookmarks_recently_deleted") + static let searchInTheList = L("search_in_the_list") + } + + private let tableView = UITableView(frame: .zero, style: .plain) + + private lazy var clearButton = UIBarButtonItem(title: LocalizedStrings.clear, style: .done, target: self, action: #selector(clearButtonDidTap)) + private lazy var recoverButton = UIBarButtonItem(title: LocalizedStrings.recover, style: .done, target: self, action: #selector(recoverButtonDidTap)) + private lazy var deleteButton = UIBarButtonItem(title: LocalizedStrings.delete, style: .done, target: self, action: #selector(deleteButtonDidTap)) + private let searchController = UISearchController(searchResultsController: nil) + private let viewModel: RecentlyDeletedCategoriesViewModel + + init(viewModel: RecentlyDeletedCategoriesViewModel = RecentlyDeletedCategoriesViewModel(bookmarksManager: BookmarksManager.shared())) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + + viewModel.stateDidChange = { [weak self] state in + self?.updateState(state) + } + viewModel.filteredDataSourceDidChange = { [weak self] dataSource in + guard let self else { return } + if dataSource.isEmpty { + self.tableView.reloadData() + } else { + let indexes = IndexSet(integersIn: 0...dataSource.count - 1) + self.tableView.update { self.tableView.reloadSections(indexes, with: .automatic) } + } + } + viewModel.onCategoriesIsEmpty = { [weak self] in + self?.goBack() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupView() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + navigationController?.setToolbarHidden(true, animated: true) + } + + private func setupView() { + extendedLayoutIncludesOpaqueBars = true + setupNavigationBar() + setupToolBar() + setupSearchBar() + setupTableView() + layout() + updateState(viewModel.state) + } + + private func setupNavigationBar() { + title = LocalizedStrings.recentlyDeleted + } + + private func setupToolBar() { + let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + toolbarItems = [flexibleSpace, recoverButton, flexibleSpace, deleteButton, flexibleSpace] + navigationController?.isToolbarHidden = false + } + + private func setupSearchBar() { + searchController.searchBar.placeholder = LocalizedStrings.searchInTheList + searchController.obscuresBackgroundDuringPresentation = false + searchController.hidesNavigationBarDuringPresentation = false + searchController.searchBar.delegate = self + searchController.searchBar.applyTheme() + navigationItem.searchController = searchController + navigationItem.hidesSearchBarWhenScrolling = true + } + + private func setupTableView() { + tableView.styleName = "TableView:PressBackground"; + tableView.allowsMultipleSelectionDuringEditing = true + tableView.register(cell: RecentlyDeletedTableViewCell.self) + tableView.setEditing(true, animated: false) + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.dataSource = self + tableView.delegate = self + } + + private func layout() { + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private func updateState(_ state: RecentlyDeletedCategoriesViewModel.State) { + switch state { + case .searching: + navigationController?.setToolbarHidden(true, animated: false) + searchController.searchBar.isUserInteractionEnabled = true + case .nothingSelected: + navigationController?.setToolbarHidden(false, animated: false) + recoverButton.title = LocalizedStrings.recoverAll + deleteButton.title = LocalizedStrings.deleteAll + searchController.searchBar.isUserInteractionEnabled = true + navigationItem.rightBarButtonItem = nil + tableView.indexPathsForSelectedRows?.forEach { tableView.deselectRow(at: $0, animated: true)} + case .someSelected: + navigationController?.setToolbarHidden(false, animated: false) + recoverButton.title = LocalizedStrings.recover + deleteButton.title = LocalizedStrings.delete + searchController.searchBar.isUserInteractionEnabled = false + navigationItem.rightBarButtonItem = clearButton + } + } + + // MARK: - Actions + @objc private func clearButtonDidTap() { + viewModel.cancelSelecting() + } + + @objc private func recoverButtonDidTap() { + viewModel.recoverSelectedCategories() + } + + @objc private func deleteButtonDidTap() { + viewModel.deleteSelectedCategories() + } +} + +// MARK: - UITableViewDataSource +extension RecentlyDeletedCategoriesViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + viewModel.filteredDataSource.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + viewModel.filteredDataSource[section].content.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(cell: RecentlyDeletedTableViewCell.self, indexPath: indexPath) + let category = viewModel.filteredDataSource[indexPath.section].content[indexPath.row] + cell.configureWith(RecentlyDeletedTableViewCell.ViewModel(category)) + return cell + } +} + +// MARK: - UITableViewDelegate +extension RecentlyDeletedCategoriesViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard tableView.isEditing else { + tableView.deselectRow(at: indexPath, animated: true) + return + } + viewModel.selectCategory(at: indexPath) + } + + func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { + guard tableView.isEditing else { return } + guard let selectedIndexPaths = tableView.indexPathsForSelectedRows, !selectedIndexPaths.isEmpty else { + viewModel.deselectAllCategories() + return + } + viewModel.deselectCategory(at: indexPath) + } + + func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let deleteAction = UIContextualAction(style: .destructive, title: LocalizedStrings.delete) { [weak self] (_, _, completion) in + self?.viewModel.deleteCategory(at: indexPath) + completion(true) + } + let recoverAction = UIContextualAction(style: .normal, title: LocalizedStrings.recover) { [weak self] (_, _, completion) in + self?.viewModel.recoverCategory(at: indexPath) + completion(true) + } + return UISwipeActionsConfiguration(actions: [deleteAction, recoverAction]) + } +} + +// MARK: - UISearchBarDelegate +extension RecentlyDeletedCategoriesViewController: UISearchBarDelegate { + func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + searchBar.setShowsCancelButton(true, animated: true) + viewModel.startSearching() + } + + func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { + searchBar.setShowsCancelButton(false, animated: true) + } + + func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + searchBar.text = nil + searchBar.resignFirstResponder() + viewModel.cancelSearching() + } + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + viewModel.search(searchText) + } +} diff --git a/iphone/Maps/Bookmarks/Categories/RecentlyDeleted/RecentlyDeletedCategoriesViewModel.swift b/iphone/Maps/Bookmarks/Categories/RecentlyDeleted/RecentlyDeletedCategoriesViewModel.swift new file mode 100644 index 0000000000..ba02cbcc3d --- /dev/null +++ b/iphone/Maps/Bookmarks/Categories/RecentlyDeleted/RecentlyDeletedCategoriesViewModel.swift @@ -0,0 +1,208 @@ +final class RecentlyDeletedCategoriesViewModel: NSObject { + + typealias BookmarksManager = RecentlyDeletedCategoriesManager & BookmarksObservable + + enum Section: CaseIterable { + struct Model: Equatable { + var content: [RecentlyDeletedCategory] + } + + case main + } + + enum State { + case searching + case nothingSelected + case someSelected + } + + private var recentlyDeletedCategoriesManager: BookmarksManager + private var dataSource: [Section.Model] = [] { + didSet { + if dataSource.isEmpty { + onCategoriesIsEmpty?() + } + } + } + private(set) var state: State = .nothingSelected + private(set) var filteredDataSource: [Section.Model] = [] + private(set) var selectedIndexPaths: [IndexPath] = [] + private(set) var searchText = String() + + var stateDidChange: ((State) -> Void)? + var filteredDataSourceDidChange: (([Section.Model]) -> Void)? + var onCategoriesIsEmpty: (() -> Void)? + + init(bookmarksManager: BookmarksManager) { + self.recentlyDeletedCategoriesManager = bookmarksManager + super.init() + subscribeOnBookmarksManagerNotifications() + fetchRecentlyDeletedCategories() + } + + deinit { + unsubscribeFromBookmarksManagerNotifications() + } + + // MARK: - Private methods + private func subscribeOnBookmarksManagerNotifications() { + recentlyDeletedCategoriesManager.add(self) + } + + private func unsubscribeFromBookmarksManagerNotifications() { + recentlyDeletedCategoriesManager.remove(self) + } + + private func updateState(to newState: State) { + guard state != newState else { return } + state = newState + stateDidChange?(state) + } + + private func updateFilteredDataSource(_ dataSource: [Section.Model]) { + filteredDataSource = dataSource.filtered(using: searchText) + filteredDataSourceDidChange?(filteredDataSource) + } + + private func updateSelectionAtIndexPath(_ indexPath: IndexPath, isSelected: Bool) { + if isSelected { + updateState(to: .someSelected) + } else { + let allDeselected = dataSource.allSatisfy { $0.content.isEmpty } + updateState(to: allDeselected ? .nothingSelected : .someSelected) + } + } + + private func removeCategories(at indexPaths: [IndexPath], completion: ([URL]) -> Void) { + var fileToRemoveURLs: [URL] + if indexPaths.isEmpty { + // Remove all without selection. + fileToRemoveURLs = dataSource.flatMap { $0.content.map { $0.fileURL } } + dataSource.removeAll() + } else { + fileToRemoveURLs = [URL]() + indexPaths.forEach { [weak self] indexPath in + guard let self else { return } + let fileToRemoveURL = self.filteredDataSource[indexPath.section].content[indexPath.row].fileURL + self.dataSource[indexPath.section].content.removeAll { $0.fileURL == fileToRemoveURL } + fileToRemoveURLs.append(fileToRemoveURL) + } + } + updateFilteredDataSource(dataSource) + updateState(to: .nothingSelected) + completion(fileToRemoveURLs) + } + + private func removeSelectedCategories(completion: ([URL]) -> Void) { + let removeAll = selectedIndexPaths.isEmpty || selectedIndexPaths.count == dataSource.flatMap({ $0.content }).count + removeCategories(at: removeAll ? [] : selectedIndexPaths, completion: completion) + selectedIndexPaths.removeAll() + updateState(to: .nothingSelected) + } +} + +// MARK: - Public methods +extension RecentlyDeletedCategoriesViewModel { + func fetchRecentlyDeletedCategories() { + let categories = recentlyDeletedCategoriesManager.getRecentlyDeletedCategories() + guard !categories.isEmpty else { return } + dataSource = [Section.Model(content: categories)] + updateFilteredDataSource(dataSource) + } + + func deleteCategory(at indexPath: IndexPath) { + removeCategories(at: [indexPath]) { recentlyDeletedCategoriesManager.deleteRecentlyDeletedCategory(at: $0) } + } + + func deleteSelectedCategories() { + removeSelectedCategories { recentlyDeletedCategoriesManager.deleteRecentlyDeletedCategory(at: $0) } + } + + func recoverCategory(at indexPath: IndexPath) { + removeCategories(at: [indexPath]) { recentlyDeletedCategoriesManager.recoverRecentlyDeletedCategories(at: $0) } + } + + func recoverSelectedCategories() { + removeSelectedCategories { recentlyDeletedCategoriesManager.recoverRecentlyDeletedCategories(at: $0) } + } + + func startSelecting() { + updateState(to: .nothingSelected) + } + + func selectCategory(at indexPath: IndexPath) { + selectedIndexPaths.append(indexPath) + updateState(to: .someSelected) + } + + func deselectCategory(at indexPath: IndexPath) { + selectedIndexPaths.removeAll { $0 == indexPath } + if selectedIndexPaths.isEmpty { + updateState(to: state == .searching ? .searching : .nothingSelected) + } + } + + func selectAllCategories() { + selectedIndexPaths = dataSource.enumerated().flatMap { sectionIndex, section in + section.content.indices.map { IndexPath(row: $0, section: sectionIndex) } + } + updateState(to: .someSelected) + } + + func deselectAllCategories() { + selectedIndexPaths.removeAll() + updateState(to: state == .searching ? .searching : .nothingSelected) + } + + func cancelSelecting() { + selectedIndexPaths.removeAll() + updateState(to: .nothingSelected) + } + + func startSearching() { + updateState(to: .searching) + } + + func cancelSearching() { + searchText.removeAll() + selectedIndexPaths.removeAll() + updateFilteredDataSource(dataSource) + updateState(to: .nothingSelected) + } + + func search(_ searchText: String) { + updateState(to: .searching) + guard !searchText.isEmpty else { + cancelSearching() + return + } + self.searchText = searchText + updateFilteredDataSource(dataSource) + } +} + +// MARK: - BookmarksObserver + +extension RecentlyDeletedCategoriesViewModel: BookmarksObserver { + func onBookmarksLoadFinished() { + fetchRecentlyDeletedCategories() + } + + func onRecentlyDeletedBookmarksCategoriesChanged() { + fetchRecentlyDeletedCategories() + } +} + + +private extension Array where Element == RecentlyDeletedCategoriesViewModel.Section.Model { + func filtered(using searchText: String) -> [Element] { + let filteredArray = map { section in + let filteredContent = section.content.filter { + guard !searchText.isEmpty else { return true } + return $0.title.localizedCaseInsensitiveContains(searchText) + } + return RecentlyDeletedCategoriesViewModel.Section.Model(content: filteredContent) + } + return filteredArray + } +} diff --git a/iphone/Maps/Bookmarks/Categories/RecentlyDeleted/RecentlyDeletedTableViewCell.swift b/iphone/Maps/Bookmarks/Categories/RecentlyDeleted/RecentlyDeletedTableViewCell.swift new file mode 100644 index 0000000000..d9cb6d7c98 --- /dev/null +++ b/iphone/Maps/Bookmarks/Categories/RecentlyDeleted/RecentlyDeletedTableViewCell.swift @@ -0,0 +1,37 @@ +final class RecentlyDeletedTableViewCell: UITableViewCell { + + struct ViewModel: Equatable, Hashable { + let fileName: String + let fileURL: URL + let deletionDate: Date + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .medium + return formatter + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configureWith(_ viewModel: ViewModel) { + textLabel?.text = viewModel.fileName + detailTextLabel?.text = Self.dateFormatter.string(from: viewModel.deletionDate) + } +} + +extension RecentlyDeletedTableViewCell.ViewModel { + init(_ category: RecentlyDeletedCategory) { + self.fileName = category.title + self.fileURL = category.fileURL + self.deletionDate = category.deletionDate + } +} diff --git a/iphone/Maps/Images.xcassets/ic_route_manager_trash_open.imageset/Contents.json b/iphone/Maps/Images.xcassets/ic_route_manager_trash_open.imageset/Contents.json index 4b5a95a592..c5bc030840 100644 --- a/iphone/Maps/Images.xcassets/ic_route_manager_trash_open.imageset/Contents.json +++ b/iphone/Maps/Images.xcassets/ic_route_manager_trash_open.imageset/Contents.json @@ -1,12 +1,15 @@ { "images" : [ { - "idiom" : "universal", - "filename" : "ic_route_manager_trash_open.pdf" + "filename" : "ic_route_manager_trash_open.pdf", + "idiom" : "universal" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } -} \ No newline at end of file +} diff --git a/iphone/Maps/Maps.xcodeproj/project.pbxproj b/iphone/Maps/Maps.xcodeproj/project.pbxproj index 188673e516..6cb86a4922 100644 --- a/iphone/Maps/Maps.xcodeproj/project.pbxproj +++ b/iphone/Maps/Maps.xcodeproj/project.pbxproj @@ -493,6 +493,11 @@ EDBD68072B625724005DD151 /* LocationServicesDisabledAlert.xib in Resources */ = {isa = PBXBuildFile; fileRef = EDBD68062B625724005DD151 /* LocationServicesDisabledAlert.xib */; }; EDBD680B2B62572E005DD151 /* LocationServicesDisabledAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDBD680A2B62572E005DD151 /* LocationServicesDisabledAlert.swift */; }; EDC3573B2B7B5029001AE9CA /* CALayer+SetCorner.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC3573A2B7B5029001AE9CA /* CALayer+SetCorner.swift */; }; + EDC4E34B2C5D1BEF009286A2 /* RecentlyDeletedCategoriesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC4E3472C5D1BEF009286A2 /* RecentlyDeletedCategoriesViewController.swift */; }; + EDC4E34C2C5D1BEF009286A2 /* RecentlyDeletedCategoriesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC4E3482C5D1BEF009286A2 /* RecentlyDeletedCategoriesViewModel.swift */; }; + EDC4E34D2C5D1BEF009286A2 /* RecentlyDeletedTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC4E3492C5D1BEF009286A2 /* RecentlyDeletedTableViewCell.swift */; }; + EDC4E3612C5E2576009286A2 /* RecentlyDeletedCategoriesViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC4E3412C5D1BD3009286A2 /* RecentlyDeletedCategoriesViewModelTests.swift */; }; + EDC4E3692C5E6F5B009286A2 /* MockRecentlyDeletedCategoriesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC4E3402C5D1BD3009286A2 /* MockRecentlyDeletedCategoriesManager.swift */; }; EDE243DD2B6D2E640057369B /* AboutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDE243D52B6CF3980057369B /* AboutController.swift */; }; EDE243E52B6D3F400057369B /* OSMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDE243E42B6D3F400057369B /* OSMView.swift */; }; EDE243E72B6D55610057369B /* InfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDE243E02B6D3EA00057369B /* InfoView.swift */; }; @@ -1415,6 +1420,11 @@ EDBD68062B625724005DD151 /* LocationServicesDisabledAlert.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LocationServicesDisabledAlert.xib; sourceTree = ""; }; EDBD680A2B62572E005DD151 /* LocationServicesDisabledAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationServicesDisabledAlert.swift; sourceTree = ""; }; EDC3573A2B7B5029001AE9CA /* CALayer+SetCorner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CALayer+SetCorner.swift"; sourceTree = ""; }; + EDC4E3402C5D1BD3009286A2 /* MockRecentlyDeletedCategoriesManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockRecentlyDeletedCategoriesManager.swift; sourceTree = ""; }; + EDC4E3412C5D1BD3009286A2 /* RecentlyDeletedCategoriesViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecentlyDeletedCategoriesViewModelTests.swift; sourceTree = ""; }; + EDC4E3472C5D1BEF009286A2 /* RecentlyDeletedCategoriesViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecentlyDeletedCategoriesViewController.swift; sourceTree = ""; }; + EDC4E3482C5D1BEF009286A2 /* RecentlyDeletedCategoriesViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecentlyDeletedCategoriesViewModel.swift; sourceTree = ""; }; + EDC4E3492C5D1BEF009286A2 /* RecentlyDeletedTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecentlyDeletedTableViewCell.swift; sourceTree = ""; }; EDE243D52B6CF3980057369B /* AboutController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutController.swift; sourceTree = ""; }; EDE243E02B6D3EA00057369B /* InfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoView.swift; sourceTree = ""; }; EDE243E42B6D3F400057369B /* OSMView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSMView.swift; sourceTree = ""; }; @@ -2070,6 +2080,7 @@ 3404F4A02028A6C00090E401 /* Categories */ = { isa = PBXGroup; children = ( + EDC4E34A2C5D1BEF009286A2 /* RecentlyDeleted */, 33046837219C605E0041F3A8 /* Category settings */, 343D7B6D202AF4CA007D56A8 /* Actions */, 3404F48F202898CC0090E401 /* BMCModels.swift */, @@ -3076,6 +3087,7 @@ ED1ADA312BC6B19E0029209F /* Tests */ = { isa = PBXGroup; children = ( + EDC4E3442C5D1BD3009286A2 /* Bookmarks */, 4B4153B82BF970B800EE4B02 /* Classes */, 4B4153B62BF9709100EE4B02 /* Core */, ); @@ -3129,6 +3141,41 @@ path = ColorPicker; sourceTree = ""; }; + EDC4E3422C5D1BD3009286A2 /* RecentlyDeletedTests */ = { + isa = PBXGroup; + children = ( + EDC4E3402C5D1BD3009286A2 /* MockRecentlyDeletedCategoriesManager.swift */, + EDC4E3412C5D1BD3009286A2 /* RecentlyDeletedCategoriesViewModelTests.swift */, + ); + path = RecentlyDeletedTests; + sourceTree = ""; + }; + EDC4E3432C5D1BD3009286A2 /* Categories */ = { + isa = PBXGroup; + children = ( + EDC4E3422C5D1BD3009286A2 /* RecentlyDeletedTests */, + ); + path = Categories; + sourceTree = ""; + }; + EDC4E3442C5D1BD3009286A2 /* Bookmarks */ = { + isa = PBXGroup; + children = ( + EDC4E3432C5D1BD3009286A2 /* Categories */, + ); + path = Bookmarks; + sourceTree = ""; + }; + EDC4E34A2C5D1BEF009286A2 /* RecentlyDeleted */ = { + isa = PBXGroup; + children = ( + EDC4E3472C5D1BEF009286A2 /* RecentlyDeletedCategoriesViewController.swift */, + EDC4E3482C5D1BEF009286A2 /* RecentlyDeletedCategoriesViewModel.swift */, + EDC4E3492C5D1BEF009286A2 /* RecentlyDeletedTableViewCell.swift */, + ); + path = RecentlyDeleted; + sourceTree = ""; + }; EDE8EAE32C2DB74A002777F5 /* OpenInAppActionSheet */ = { isa = PBXGroup; children = ( @@ -4351,6 +4398,7 @@ 3463BA671DE81DB90082417F /* MWMTrafficButtonViewController.mm in Sources */, ED79A5D52BDF8D6100952D1F /* SynchronizationError.swift in Sources */, 993DF10323F6BDB100AC231A /* MainTheme.swift in Sources */, + EDC4E34D2C5D1BEF009286A2 /* RecentlyDeletedTableViewCell.swift in Sources */, 34AB66051FC5AA320078E451 /* MWMNavigationDashboardManager+Entity.mm in Sources */, 993DF12A23F6BDB100AC231A /* Style.swift in Sources */, 34ABA6171C2D185C00FE1BEC /* MWMAuthorizationOSMLoginViewController.mm in Sources */, @@ -4443,6 +4491,7 @@ 342CC5F21C2D7730005F3FE5 /* MWMAuthorizationLoginViewController.mm in Sources */, 340475591E081A4600C92850 /* WebViewController.m in Sources */, 3404F4992028A20D0090E401 /* BMCCategoryCell.swift in Sources */, + EDC4E34C2C5D1BEF009286A2 /* RecentlyDeletedCategoriesViewModel.swift in Sources */, F62607FD207B790300176C5A /* SpinnerAlert.swift in Sources */, 3444DFD21F17620C00E73099 /* MWMMapWidgetsHelper.mm in Sources */, 3472B5E1200F86C800DC6CD5 /* MWMEditorHelper.mm in Sources */, @@ -4492,6 +4541,7 @@ 3454D7C21E07F045004AF2AD /* NSString+Categories.m in Sources */, 34E7761F1F14DB48003040B3 /* PlacePageArea.swift in Sources */, ED79A5D82BDF8D6100952D1F /* DefaultLocalDirectoryMonitor.swift in Sources */, + EDC4E34B2C5D1BEF009286A2 /* RecentlyDeletedCategoriesViewController.swift in Sources */, ED8270F02C2071A3005966DA /* SettingsTableViewDetailedSwitchCell.swift in Sources */, 4728F69322CF89A400E00028 /* GradientView.swift in Sources */, F6381BF61CD12045004CA943 /* LocaleTranslator.mm in Sources */, @@ -4677,9 +4727,11 @@ ED1ADA332BC6B1B40029209F /* CarPlayServiceTests.swift in Sources */, EDF838C32C00B9D6007E4E67 /* UbiquitousDirectoryMonitorDelegateMock.swift in Sources */, EDF838BE2C00B9D0007E4E67 /* LocalDirectoryMonitorDelegateMock.swift in Sources */, + EDC4E3692C5E6F5B009286A2 /* MockRecentlyDeletedCategoriesManager.swift in Sources */, EDF838BF2C00B9D0007E4E67 /* SynchronizationStateManagerTests.swift in Sources */, 4B83AE4B2C2E642100B0C3BC /* TTSTesterTest.m in Sources */, EDF838C22C00B9D6007E4E67 /* MetadataItemStubs.swift in Sources */, + EDC4E3612C5E2576009286A2 /* RecentlyDeletedCategoriesViewModelTests.swift in Sources */, 4B4153B52BF9695500EE4B02 /* MWMTextToSpeechTests.mm in Sources */, EDF838C42C00B9D6007E4E67 /* FileManagerMock.swift in Sources */, ); diff --git a/iphone/Maps/Tests/Bookmarks/Categories/RecentlyDeletedTests/MockRecentlyDeletedCategoriesManager.swift b/iphone/Maps/Tests/Bookmarks/Categories/RecentlyDeletedTests/MockRecentlyDeletedCategoriesManager.swift new file mode 100644 index 0000000000..dac4039fbc --- /dev/null +++ b/iphone/Maps/Tests/Bookmarks/Categories/RecentlyDeletedTests/MockRecentlyDeletedCategoriesManager.swift @@ -0,0 +1,32 @@ +class MockRecentlyDeletedCategoriesManager: NSObject, RecentlyDeletedCategoriesManager, BookmarksObservable { + + var categories = [RecentlyDeletedCategory]() + + func recentlyDeletedCategoriesCount() -> UInt64 { + UInt64(categories.count) + } + + func getRecentlyDeletedCategories() -> [RecentlyDeletedCategory] { + categories + } + + func deleteFile(at urls: [URL]) { + categories.removeAll { urls.contains($0.fileURL) } + } + + func deleteAllRecentlyDeletedCategories() { + categories.removeAll() + } + + func recoverRecentlyDeletedCategories(at urls: [URL]) { + categories.removeAll { urls.contains($0.fileURL) } + } + + func deleteRecentlyDeletedCategory(at urls: [URL]) { + categories.removeAll { urls.contains($0.fileURL) } + } + + func add(_ observer: any BookmarksObserver) {} + + func remove(_ observer: any BookmarksObserver) {} +} diff --git a/iphone/Maps/Tests/Bookmarks/Categories/RecentlyDeletedTests/RecentlyDeletedCategoriesViewModelTests.swift b/iphone/Maps/Tests/Bookmarks/Categories/RecentlyDeletedTests/RecentlyDeletedCategoriesViewModelTests.swift new file mode 100644 index 0000000000..b400c929ee --- /dev/null +++ b/iphone/Maps/Tests/Bookmarks/Categories/RecentlyDeletedTests/RecentlyDeletedCategoriesViewModelTests.swift @@ -0,0 +1,203 @@ +import XCTest +@testable import Organic_Maps__Debug_ + +final class RecentlyDeletedCategoriesViewModelTests: XCTestCase { + var viewModel: RecentlyDeletedCategoriesViewModel! + var bookmarksManagerMock: MockRecentlyDeletedCategoriesManager! + + override func setUp() { + super.setUp() + bookmarksManagerMock = MockRecentlyDeletedCategoriesManager() + setupBookmarksManagerStubs() + + viewModel = RecentlyDeletedCategoriesViewModel(bookmarksManager: bookmarksManagerMock) + } + + override func tearDown() { + viewModel = nil + bookmarksManagerMock = nil + super.tearDown() + } + + private func setupBookmarksManagerStubs() { + bookmarksManagerMock.categories = [ + RecentlyDeletedCategory(title: "test1", fileURL: URL(string: "test1")!, deletionDate: Date()), + RecentlyDeletedCategory(title: "test2", fileURL: URL(string: "test2")!, deletionDate: Date()), + RecentlyDeletedCategory(title: "lol", fileURL: URL(string: "lol")!, deletionDate: Date()), + RecentlyDeletedCategory(title: "te1", fileURL: URL(string: "te1")!, deletionDate: Date()), + ] + } + + func testInitializationFetchesCategories() { + XCTAssertEqual(viewModel.state, .nothingSelected) + XCTAssertEqual(viewModel.filteredDataSource.flatMap { $0.content }.count, Int(bookmarksManagerMock.recentlyDeletedCategoriesCount())) + } + + // MARK: - Selection Tests + func testMultipleSelectionAndDeselection() { + viewModel.selectAllCategories() + let initialSelectedCount = viewModel.selectedIndexPaths.count + XCTAssertEqual(initialSelectedCount, viewModel.filteredDataSource.flatMap { $0.content }.count) + + viewModel.deselectAllCategories() + XCTAssertTrue(viewModel.selectedIndexPaths.isEmpty) + } + + func testSelectAndDeselectSpecificCategory() { + let specificIndexPath = IndexPath(row: 0, section: 0) + viewModel.selectCategory(at: specificIndexPath) + XCTAssertTrue(viewModel.selectedIndexPaths.contains(specificIndexPath)) + + viewModel.deselectCategory(at: specificIndexPath) + XCTAssertFalse(viewModel.selectedIndexPaths.contains(specificIndexPath)) + } + + func testSelectAndDeselectSpecificCategories() { + let indexPath1 = IndexPath(row: 0, section: 0) + let indexPath2 = IndexPath(row: 1, section: 0) + let indexPath3 = IndexPath(row: 2, section: 0) + viewModel.selectCategory(at: indexPath1) + viewModel.selectCategory(at: indexPath2) + viewModel.selectCategory(at: indexPath3) + XCTAssertTrue(viewModel.selectedIndexPaths.contains(indexPath1)) + XCTAssertTrue(viewModel.selectedIndexPaths.contains(indexPath2)) + XCTAssertTrue(viewModel.selectedIndexPaths.contains(indexPath3)) + + viewModel.deselectCategory(at: indexPath1) + XCTAssertFalse(viewModel.selectedIndexPaths.contains(indexPath1)) + XCTAssertEqual(viewModel.state, .someSelected) + + viewModel.deselectCategory(at: indexPath2) + viewModel.deselectCategory(at: indexPath3) + XCTAssertEqual(viewModel.selectedIndexPaths.count, .zero) + XCTAssertEqual(viewModel.state, .nothingSelected) + } + + func testStateChangesOnSelection() { + let indexPath = IndexPath(row: 1, section: 0) + viewModel.selectCategory(at: indexPath) + XCTAssertEqual(viewModel.state, .someSelected) + + viewModel.deselectCategory(at: indexPath) + XCTAssertEqual(viewModel.state, .nothingSelected) + } + + func testStateChangesOnDone() { + let indexPath = IndexPath(row: 1, section: 0) + viewModel.selectCategory(at: indexPath) + XCTAssertEqual(viewModel.state, .someSelected) + + viewModel.cancelSelecting() + XCTAssertEqual(viewModel.filteredDataSource.flatMap { $0.content }.count, Int(bookmarksManagerMock.recentlyDeletedCategoriesCount())) + } + + // MARK: - Searching Tests + func testSearchWithEmptyString() { + viewModel.search("") + XCTAssertEqual(viewModel.filteredDataSource.flatMap { $0.content }.count, 4) + } + + func testSearchWithNoResults() { + viewModel.search("xyz") // Assuming "xyz" matches no category names + XCTAssertTrue(viewModel.filteredDataSource.allSatisfy { $0.content.isEmpty }) + } + + func testCancelSearchRestoresDataSource() { + let searchText = "test" + viewModel.search(searchText) + XCTAssertEqual(viewModel.state, .searching) + XCTAssertTrue(viewModel.filteredDataSource.allSatisfy { $0.content.allSatisfy { $0.title.localizedCaseInsensitiveContains(searchText) } }) + XCTAssertEqual(viewModel.filteredDataSource.flatMap { $0.content }.count, 2) + + viewModel.cancelSearching() + XCTAssertEqual(viewModel.state, .nothingSelected) + XCTAssertEqual(viewModel.filteredDataSource.flatMap { $0.content }.count, 4) + } + + // MARK: - Deletion Tests + func testDeleteCategory() { + let initialCount = bookmarksManagerMock.categories.count + viewModel.deleteCategory(at: IndexPath(row: 0, section: 0)) + XCTAssertEqual(bookmarksManagerMock.categories.count, initialCount - 1) + } + + func testDeleteAllWhenNoOneIsSelected() { + viewModel.deleteSelectedCategories() + XCTAssertEqual(bookmarksManagerMock.categories.count, .zero) + } + + func testDeleteAllWhenNoSoneAreSelected() { + viewModel.selectCategory(at: IndexPath(row: 0, section: 0)) + viewModel.selectCategory(at: IndexPath(row: 1, section: 0)) + viewModel.deleteSelectedCategories() + XCTAssertEqual(viewModel.state, .nothingSelected) + XCTAssertEqual(bookmarksManagerMock.categories.count, 2) + XCTAssertEqual(viewModel.filteredDataSource.flatMap { $0.content }.count, 2) + XCTAssertEqual(viewModel.filteredDataSource.flatMap { $0.content }.count, Int(bookmarksManagerMock.recentlyDeletedCategoriesCount())) + } + + // MARK: - Recovery Tests + func testRecoverCategory() { + viewModel.recoverCategory(at: IndexPath(row: 0, section: 0)) + XCTAssertEqual(viewModel.state, .nothingSelected) + XCTAssertEqual(bookmarksManagerMock.categories.count, 3) + XCTAssertEqual(viewModel.state, .nothingSelected) + } + + func testRecoverAll() { + viewModel.recoverSelectedCategories() + XCTAssertEqual(viewModel.state, .nothingSelected) + XCTAssertEqual(bookmarksManagerMock.categories.count, 0) + } + + func testRecoverAllWhenSomeAreSelected() { + viewModel.selectCategory(at: IndexPath(row: 0, section: 0)) + viewModel.selectCategory(at: IndexPath(row: 1, section: 0)) + viewModel.recoverSelectedCategories() + XCTAssertEqual(viewModel.state, .nothingSelected) + XCTAssertEqual(bookmarksManagerMock.categories.count, 2) + XCTAssertEqual(viewModel.filteredDataSource.flatMap { $0.content }.count, Int(bookmarksManagerMock.recentlyDeletedCategoriesCount())) + } + + func testSearchFiltersCategories() { + var searchText = "test" + viewModel.search(searchText) + XCTAssertEqual(viewModel.state, .searching) + XCTAssertTrue(viewModel.filteredDataSource.allSatisfy { $0.content.allSatisfy { $0.title.localizedCaseInsensitiveContains(searchText) } }) + + searchText = "te" + viewModel.search(searchText) + XCTAssertEqual(viewModel.state, .searching) + XCTAssertTrue(viewModel.filteredDataSource.allSatisfy { $0.content.allSatisfy { $0.title.localizedCaseInsensitiveContains(searchText) } }) + } + + func testDeleteAllCategories() { + viewModel.deleteSelectedCategories() + XCTAssertTrue(bookmarksManagerMock.categories.isEmpty) + } + + func testRecoverAllCategories() { + viewModel.recoverSelectedCategories() + XCTAssertTrue(bookmarksManagerMock.categories.isEmpty) + } + + func testDeleteAndRecoverAllCategoriesWhenEmpty() { + bookmarksManagerMock.categories = [] + viewModel.fetchRecentlyDeletedCategories() + viewModel.deleteSelectedCategories() + viewModel.recoverSelectedCategories() + XCTAssertTrue(viewModel.filteredDataSource.isEmpty) + } + + func testMultipleStateTransitions() { + viewModel.startSelecting() + XCTAssertEqual(viewModel.state, .nothingSelected) + + viewModel.startSearching() + XCTAssertEqual(viewModel.state, .searching) + + viewModel.cancelSearching() + viewModel.cancelSelecting() + XCTAssertEqual(viewModel.state, .nothingSelected) + } +}