// // TableView.swift // CapFinder // // Created by User on 22.04.18. // Copyright © 2018 User. All rights reserved. // import UIKit enum NavigationBarDataType { case appInfo case upload case thumbnails } protocol NavigationBarDataSource { var title: String { get } var subtitle: String { get } var id: NavigationBarDataType { get } } class TableView: UITableViewController { @IBOutlet weak var infoButton: UIBarButtonItem! private lazy var classifier: Classifier? = loadClassifier() private var accessory: SearchAndDisplayAccessory? private var titleLabel: UILabel! private var subtitleLabel: UILabel! private var caps = [Cap]() private var shownCaps = [Cap]() private var matches: [Int : Float]? private var sortType: SortCriteria = .id private var searchText: String? = nil private var sortAscending: Bool = false /// This will be set to a cap id when adding a cap to it private var capToAddImageTo: Int? private var isUnlocked = false var imageProvider: ImageProvider { app.database.storage } // MARK: Computed properties private var titleText: String { let recognized = app.database.recognizedCapCount let all = app.database.capCount switch all { case 0: return "No caps" case 1: return "1 cap" case recognized: return "\(all) caps" default: return "\(all) caps (\(all - recognized) new)" } } private var subtitleText: String { let capCount = app.database.capCount guard capCount > 0, isUnlocked else { return "" } let allImages = app.database.imageCount let ratio = Float(allImages) / Float(capCount) return String(format: "%d images (%.2f per cap)", allImages, ratio) } // MARK: Overrides override var inputAccessoryView: UIView? { get { return accessory } } override var canBecomeFirstResponder: Bool { return true } // MARK: - Actions @IBAction func updateInfo(_ sender: UIBarButtonItem, forEvent event: UIEvent) { guard let touch = event.allTouches?.first, touch.tapCount > 0 else { return } guard !app.database.isInOfflineMode else { showOfflineDialog() return } app.database.startInitialDownload() } @IBAction func showMosaic(_ sender: UIBarButtonItem) { checkThumbnailsAndColorsBeforShowingGrid() } func showCameraView() { let storyboard = UIStoryboard(name: "Main", bundle: nil) let controller = storyboard.instantiateViewController(withIdentifier: "NewImageController") as! CameraController controller.delegate = self self.present(controller, animated: true) } @objc private func titleWasTapped() { let storyboard = UIStoryboard(name: "Main", bundle: nil) let controller = storyboard.instantiateViewController(withIdentifier: "SortController") as! SortController controller.selected = sortType controller.ascending = sortAscending controller.delegate = self controller.options = [.id, .name] if isUnlocked { controller.options.append(.count) } if matches != nil { controller.options.append(.match) } let presentationController = AlwaysPresentAsPopover.configurePresentation(forController: controller) presentationController.sourceView = navigationItem.titleView! presentationController.permittedArrowDirections = [.up] self.present(controller, animated: true) } // MARK: - Life cycle override func viewDidLoad() { super.viewDidLoad() tableView.rowHeight = 100 accessory = SearchAndDisplayAccessory(width: self.view.frame.width) accessory?.delegate = self initInfoButton() app.database.delegate = self let count = app.database.capCount if count == 0 { log("No caps found, downloading names") app.database.startInitialDownload() } else { log("Loaded \(count) caps") reloadCapsFromDatabase() } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) (navigationController as? NavigationController)?.allowLandscape = false isUnlocked = app.isUnlocked log(isUnlocked ? "App is unlocked" : "App is locked") app.database.startBackgroundWork() } override func didMove(toParent parent: UIViewController?) { super.didMove(toParent: parent) guard parent != nil && self.navigationItem.titleView == nil else { return } initNavigationItemTitleView() } private func initInfoButton() { let offline = app.database.isInOfflineMode setInfoButtonIcon(offline: offline) } private func setInfoButtonIcon(offline: Bool) { let symbol = offline ? "icloud.slash" : "arrow.clockwise.icloud" infoButton.image = UIImage(systemName: symbol) } private func initNavigationItemTitleView() { self.titleLabel = UILabel() titleLabel.text = titleText titleLabel.font = .preferredFont(forTextStyle: .headline) titleLabel.textColor = .label self.subtitleLabel = UILabel() subtitleLabel.text = subtitleText subtitleLabel.font = .preferredFont(forTextStyle: .footnote) subtitleLabel.textColor = .secondaryLabel let stackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) stackView.distribution = .equalCentering stackView.alignment = .center stackView.axis = .vertical self.navigationItem.titleView = stackView let recognizer = UITapGestureRecognizer(target: self, action: #selector(titleWasTapped)) stackView.isUserInteractionEnabled = true stackView.addGestureRecognizer(recognizer) let longPress = UILongPressGestureRecognizer(target: self, action: #selector(attemptChangeOfUserPermissions)) stackView.addGestureRecognizer(longPress) } private func set(title: String, subtitle: String) { DispatchQueue.main.async { self.titleLabel?.text = title self.subtitleLabel?.text = subtitle } } private func updateNavigationItemTitleView() { DispatchQueue.main.async { self.titleLabel?.text = self.titleText self.subtitleLabel?.text = self.subtitleText } } // MARK: Starting updates private func checkThumbnailsAndColorsBeforShowingGrid() { let colors = app.database.pendingCapsForColorCreation let thumbs = app.database.pendingCapForThumbnailCreation guard colors == 0 && thumbs == 0 else { app.database.startBackgroundWork() showAlert("Please wait until all background work is completed. \(colors) colors and \(thumbs) thumbnails need to be created.", title: "Mosaic not ready") return } showGrid() } private func showGrid() { let vc = app.mainStoryboard.instantiateViewController(withIdentifier: "GridView") as! GridViewController guard let nav = navigationController as? NavigationController else { return } if let tileImage = app.database.tileImage(named: "default") { log("Showing existing tile image") vc.load(tileImage: tileImage) } else { let tileImage = Database.TileImage(name: "default", width: 40, caps: []) log("Showing default tile image") vc.load(tileImage: tileImage) } nav.pushViewController(vc, animated: true) nav.allowLandscape = true } private func showOfflineDialog() { let offline = app.database.isInOfflineMode if offline { print("Marking as online") app.database.isInOfflineMode = false app.database.startBackgroundWork() self.showAlert("Offline mode was disabled", title: "Online") } else { print("Marking as offline") app.database.isInOfflineMode = true self.showAlert("Offline mode was enabled", title: "Offline") } } private func rename(cap: Cap, at indexPath: IndexPath) { let detail = "Choose a new name for the cap" askUserForText("Enter new name", detail: detail, existingText: cap.name, yesText: "Save") { text in guard app.database.update(name: text, for: cap.id) else { self.showAlert("Name could not be set.", title: "Update failed") return } } } private func saveNewCap(for image: UIImage) { let detail = "Choose a name for the image" askUserForText("Enter name", detail: detail, existingText: accessory!.searchBar.text, yesText: "Save") { text in DispatchQueue.global(qos: .userInitiated).async { guard app.database.createCap(image: image, name: text) else { self.showAlert("Cap not added", title: "Database error") return } self.accessory!.discardImage() } } } private func updateShownCaps(_ newList: [Cap], insertedId id: Int) { // Main queue guard shownCaps.count == newList.count - 1 else { log("Cap list refresh mismatch: was \(shownCaps.count), is \(newList.count)") show(sortedCaps: newList) return } guard let index = newList.firstIndex(where: { $0.id == id}) else { log("Cap list refresh without new cap \(id)") show(sortedCaps: newList) return } self.tableView.beginUpdates() self.shownCaps = newList let indexPath = IndexPath(row: index, section: 0) self.tableView.insertRows(at: [indexPath], with: .automatic) self.tableView.endUpdates() } // MARK: User interaction @objc private func attemptChangeOfUserPermissions() { guard isUnlocked else { attemptAppUnlock() return } log("Locking app.") app.lock() isUnlocked = false showAllCapsAndScrollToTop() updateNavigationItemTitleView() showAlert("The app was locked to prevent modifications.", title: "Locked") } private func attemptAppUnlock() { log("Presenting unlock dialog to user") askUserForText("Enter pin", detail: "Enter the correct pin to unlock write permissions for the app.", placeholder: "Pin", yesText: "Unlock") { text in guard let pin = Int(text), app.checkUnlock(with: pin) else { self.unlockFailed() return } self.unlockDidSucceed() } } private func unlockFailed() { showAlert("The pin you entered is incorrect.", title: "Invalid pin") } private func unlockDidSucceed() { showAlert("The app was successfully unlocked.", title: "Unlocked") isUnlocked = true showAllCapsAndScrollToTop() updateNavigationItemTitleView() } private func loadClassifier() -> Classifier? { guard let model = app.database.storage.recognitionModel else { return nil } return Classifier(model: model) } private func askUserForText(_ title: String, detail: String, existingText: String? = nil, placeholder: String? = "Cap name", yesText: String, noText: String = "Cancel", confirmed: @escaping (_ text: String) -> Void) { DispatchQueue.main.async { let alertController = UIAlertController( title: title, message: detail, preferredStyle: .alert) alertController.addTextField { textField in textField.placeholder = placeholder textField.keyboardType = .default textField.text = existingText } let action = UIAlertAction(title: yesText, style: .default) { _ in guard let name = alertController.textFields?.first?.text else { return } confirmed(name) } let cancel = UIAlertAction(title: noText, style: .cancel) alertController.addAction(action) alertController.addAction(cancel) self.present(alertController, animated: true) } } private func presentUserBinaryChoice(_ title: String, detail: String, yesText: String, noText: String = "Cancel", dismissed: (() -> Void)? = nil, confirmed: @escaping () -> Void) { let alert = UIAlertController(title: title, message: detail, preferredStyle: .alert) let confirm = UIAlertAction(title: yesText, style: .default) { _ in confirmed() } let cancel = UIAlertAction(title: noText, style: .cancel) { _ in dismissed?() } alert.addAction(confirm) alert.addAction(cancel) DispatchQueue.main.async { self.present(alert, animated: true) } } // MARK: Classification /// The similarity of the cap to the currently processed image private func match(for cap: Int) -> Float? { matches?[cap] } private func clearClassifierMatches() { matches = nil } private func classify(image: UIImage) { guard let classifier = self.classifier else { return } DispatchQueue.global(qos: .userInitiated).async { self.log("Classification starting...") classifier.recognize(image: image) { matches in guard let matches = matches else { self.log("Failed to classify image") self.matches = nil return } self.log("Classification finished") self.matches = matches self.sortType = .match self.sortAscending = false self.showAllCapsAndScrollToTop() DispatchQueue.global(qos: .background).async { app.database.update(recognizedCaps: Set(matches.keys)) } } } } private func classifyDummyImage() { guard let classifier = self.classifier else { return } DispatchQueue.global(qos: .userInitiated).async { classifier.recognize(image: UIImage(named: "launch")!) { matches in guard let matches = matches else { self.log("Failed to classify dummy image") self.matches = nil return } self.log("Dummy classification finished") DispatchQueue.global(qos: .background).async { app.database.update(recognizedCaps: Set(matches.keys)) } } } } // MARK: Finishing downloads private func didDownloadClassifier() { guard let model = app.database.storage.recognitionModel else { classifier = nil return } classifier = Classifier(model: model) guard let image = accessory!.currentImage else { classifyDummyImage() return } classify(image: image) } // MARK: - Showing caps private func reloadCapsFromDatabase() { caps = app.database?.caps ?? [] showCaps() } /** Match all cap names against the given string and return matches. - note: Each space-separated part of the string is matched individually */ private func showCaps(matching text: String? = nil) { DispatchQueue.global(qos: .userInteractive).async { self.searchText = text guard let t = text else { self.show(caps: self.caps) return } let found = self.filter(caps: self.caps, matching: t) self.show(caps: found) } } private func show(caps: [Cap]) { show(sortedCaps: sorted(caps: caps)) } private func show(sortedCaps caps: [Cap]) { shownCaps = caps DispatchQueue.main.async { self.tableView.reloadData() } } private func filter(caps: [Cap], matching text: String) -> [Cap] { let textParts = text.components(separatedBy: " ").filter { $0 != "" } return caps.compactMap { cap -> Cap? in // For each part of text, check if name contains it for textItem in textParts { if !cap.cleanName.contains(textItem) { return nil } } return cap } } private func sorted(caps: [Cap]) -> [Cap] { if sortAscending { switch sortType { case .id: return caps.sorted { $0.id < $1.id } case .count: return caps.sorted { $0.count < $1.count } case .name: return caps.sorted { $0.name < $1.name } case .match: return caps.sorted { match(for: $0.id) ?? 0 < match(for: $1.id) ?? 0 } } } else { switch sortType { case .id: return caps.sorted { $0.id > $1.id } case .count: return caps.sorted { $0.count > $1.count } case .name: return caps.sorted { $0.name > $1.name } case .match: return caps.sorted { match(for: $0.id) ?? 0 > match(for: $1.id) ?? 0 } } } } /// Resets the cap list to its original state, discarding any previous sorting. private func showAllCapsByDescendingId() { sortType = .id sortAscending = false showAllCapsAndScrollToTop() } /// Display all caps in the table, and scrolls to the top private func showAllCapsAndScrollToTop() { showCaps() tableViewScrollToTop() } // MARK: - TableView /** Scroll the table view to the top */ private func tableViewScrollToTop() { guard shownCaps.count > 0 else { return } let path = IndexPath(row: 0, section: 0) DispatchQueue.main.async { self.tableView.scrollToRow(at: path, at: .top, animated: true) } } } // MARK: - SortControllerDelegate extension TableView: SortControllerDelegate { func sortController(didSelect sortType: SortCriteria, ascending: Bool) { self.sortType = sortType self.sortAscending = ascending if sortType != .match { clearClassifierMatches() } showAllCapsAndScrollToTop() } } // MARK: - CameraControllerDelegate extension TableView: CameraControllerDelegate { func didCapture(image: UIImage) { guard let cap = capToAddImageTo else { accessory!.showImageView(with: image) classify(image: image) return } guard app.database.add(image: image, for: cap) else { self.error("Could not save image") return } log("Added image for cap \(cap)") self.capToAddImageTo = nil } func didCancel() { capToAddImageTo = nil } } // MARK: - UITableViewDataSource extension TableView { override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "cap") as! CapCell let cap = shownCaps[indexPath.row] configure(cell: cell, for: cap) return cell } private func configure(cell: CapCell, for cap: Cap) { let matchText = cap.matchLabelText(match: match(for: cap.id), appIsUnlocked: self.isUnlocked) let countText = cap.countLabelText(appIsUnlocked: self.isUnlocked) cell.id = cap.id cell.set(name: cap.name) cell.set(matchLabel: matchText) cell.set(countLabel: countText) if let image = imageProvider.image(for: cap.id) { cell.set(image: image) } else { cell.set(image: nil) app.database.downloadImage(for: cap.id) { _ in // Delegate call will update image } } } override func numberOfSections(in tableView: UITableView) -> Int { return 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return shownCaps.count } } // MARK: - UITableViewDelegate extension TableView { private func takeImage(for cap: Int) { self.capToAddImageTo = cap showCameraView() } override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { // Prevent unauthorized users from selecting caps isUnlocked ? indexPath : nil } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { defer { tableView.deselectRow(at: indexPath, animated: true) } // Prevent unauthorized users from making changes guard isUnlocked else { return } let cap = shownCaps[indexPath.row] guard let image = accessory?.capImage.image else { self.giveFeedback(.medium) takeImage(for: cap.id) return } guard app.database.add(image: image, for: cap.id) else { self.giveFeedback(.heavy) self.error("Could not save image") return } self.giveFeedback(.medium) // Delegate call will update cell self.accessory?.discardImage() } private func giveFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle) { UIImpactFeedbackGenerator(style: style).impactOccurred() } override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { // Prevent unauthorized users from making changes guard isUnlocked else { return nil } let cap = shownCaps[indexPath.row] let rename = UIContextualAction(style: .normal, title: "Rename\ncap") { (_, _, success) in success(true) self.rename(cap: cap, at: indexPath) self.giveFeedback(.medium) } rename.backgroundColor = .blue let image = UIContextualAction(style: .normal, title: "Change\nimage") { (_, _, success) in self.giveFeedback(.medium) let storyboard = UIStoryboard(name: "Main", bundle: nil) let controller = storyboard.instantiateViewController(withIdentifier: "ImageSelector") as! ImageSelector controller.cap = cap controller.imageProvider = self.imageProvider self.navigationController?.pushViewController(controller, animated: true) success(true) } image.backgroundColor = .red return UISwipeActionsConfiguration(actions: [rename, image]) } override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let cap = shownCaps[indexPath.row] var actions = [UIContextualAction]() // Prevent unauthorized users from making changes if isUnlocked { let count = UIContextualAction(style: .normal, title: "Update\ncount") { (_, _, success) in self.giveFeedback(.medium) success(true) DispatchQueue.global(qos: .userInitiated).async { app.database.downloadImageCount(for: cap.id) } } count.backgroundColor = .orange actions.append(count) } let similar = UIContextualAction(style: .normal, title: "Similar\ncaps") { (_, _, success) in self.giveFeedback(.medium) self.accessory?.hideImageView() guard let image = self.imageProvider.image(for: cap.id, version: 0) else { success(false) return } self.classify(image: image) success(true) } similar.backgroundColor = .blue actions.append(similar) return UISwipeActionsConfiguration(actions: actions) } } // MARK: - Logging extension TableView: Logger { } // MARK: - Protocol DatabaseDelegate extension TableView: DatabaseDelegate { func database(needsUserConfirmation title: String, body: String, shouldProceed: @escaping (Bool) -> Void) { presentUserBinaryChoice(title, detail: body, yesText: "Download", noText: "Later", dismissed: { shouldProceed(false) }) { shouldProceed(true) } } func databaseHasNewClassifier() { didDownloadClassifier() } func database(completedBackgroundWorkItem title: String, subtitle: String) { set(title: title, subtitle: subtitle) } func database(didFailBackgroundWork title: String, subtitle: String) { set(title: title, subtitle: subtitle) DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(10)) { self.updateNavigationItemTitleView() } } func databaseDidFinishBackgroundWork() { // set(title: "All tasks completed", subtitle: titleText) self.updateNavigationItemTitleView() // DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { // self.updateNavigationItemTitleView() // } } func database(didAddCap cap: Cap) { caps.append(cap) updateNavigationItemTitleView() guard let text = searchText else { // All caps are shown let newList = sorted(caps: caps) updateShownCaps(newList, insertedId: cap.id) return } guard filter(caps: [cap], matching: text) != [] else { // Cap is not shown, so don't reload return } let newList = sorted(caps: filter(caps: caps, matching: text)) updateShownCaps(newList, insertedId: cap.id) } func database(didChangeCap id: Int) { updateNavigationItemTitleView() guard let cap = app.database.cap(for: id) else { log("Changed cap \(id) not found in database") return } if let index = caps.firstIndex(where: { $0.id == id}) { caps[index] = cap } else { log("Cap not found in full list") } if let index = shownCaps.firstIndex(where: { $0.id == id}) { shownCaps[index] = cap } guard let cell = visibleCell(for: id) else { return } configure(cell: cell, for: cap) } func database(didLoadImageForCap cap: Int) { DispatchQueue.main.async { guard let cell = self.visibleCell(for: cap) else { return } guard let image = self.imageProvider.image(for: cap) else { self.log("No image for cap \(cap), although it should be loaded") return } cell.set(image: image) } } func databaseNeedsFullRefresh() { reloadCapsFromDatabase() } private func visibleCell(for cap: Int) -> CapCell? { tableView.visibleCells .map { $0 as! CapCell } .first { $0.id == cap } } } // MARK: - Protocol CapSearchDelegate extension TableView: CapAccessoryDelegate { func capSearchWasDismissed() { showAllCapsAndScrollToTop() } func capSearch(didChange text: String) { let cleaned = text.clean guard cleaned != "" else { self.showCaps(matching: nil) return } self.showCaps(matching: cleaned) } func capAccessoryDidDiscardImage() { matches = nil showAllCapsByDescendingId() } func capAccessory(shouldSave image: UIImage) { guard isUnlocked else { return } saveNewCap(for: image) } func capAccessoryCameraButtonPressed() { showCameraView() } }