// // TableView.swift // CapFinder // // Created by User on 22.04.18. // Copyright © 2018 User. All rights reserved. // import UIKit import JGProgressHUD class TableView: UITableViewController { private var classifier: Classifier? 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? // 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 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) { downloadNewestClassifierIfNeeded() downloadImageCounts() checkIfCapImagesNeedDownload() } @IBAction func showMosaic(_ sender: UIBarButtonItem) { let vc = app.mainStoryboard.instantiateViewController(withIdentifier: "GridView") as! GridViewController guard let nav = navigationController as? NavigationController else { return } nav.pushViewController(vc, animated: true) nav.allowLandscape = true } 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 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() app.database.add(listener: self) tableView.rowHeight = 100 accessory = SearchAndDisplayAccessory(width: self.view.frame.width) accessory?.delegate = self reloadCapsFromDatabase() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) (navigationController as? NavigationController)?.allowLandscape = false } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) checkDatabaseIsDownloaded() checkClassifierIsDownloaded() } override func didMove(toParent parent: UIViewController?) { super.didMove(toParent: parent) guard parent != nil && self.navigationItem.titleView == nil else { return } initNavigationItemTitleView() } 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) } private func updateNavigationItemTitleView() { DispatchQueue.main.async { self.titleLabel.text = self.titleText self.subtitleLabel.text = self.subtitleText } } // MARK: Starting updates private func downloadImageCounts() { app.database.downloadImageCounts() } private func downloadNewestClassifierIfNeeded() { app.database.hasNewClassifier { version, size in guard let version = version else { return } DispatchQueue.main.async { self.askUserToDownload(classifier: version, size: size) } } } 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 { return } guard let newCap = app.database.cap(for: cap.id) else { return } self.shownCaps[indexPath.row] = newCap self.tableView.reloadRows(at: [indexPath], with: .none) } } 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 guard app.database.createCap(image: image, name: text) else { self.showAlert("Cap not added") return } self.accessory!.discardImage() } } // MARK: User interaction private func checkClassifierIsDownloaded() { guard let model = app.storage.recognitionModel else { downloadNewestClassifierIfNeeded() return } classifier = Classifier(model: model) } private func checkDatabaseIsDownloaded() { guard app.needsDownload else { return } log("Server database not available, getting database size") app.database.getServerDatabaseSize { size in DispatchQueue.main.async { self.askUserToDownloadServerDatabase(size: size) } } } private func askUserToDownloadServerDatabase(size: Int64?) { let detail = "The server database needs to be downloaded for the app to function properly. Would you like to download it now?" let sizeText = size != nil ? " (\(ByteCountFormatter.string(fromByteCount: size!, countStyle: .file)))" : "" presentUserBinaryChoice("Download server database", detail: detail + sizeText, yesText: "Download", noText: "Later") { self.downloadServerDatabase() } } private func checkIfCapImagesNeedDownload() { let count = app.database.capsWithoutImages guard count > 0 else { log("No cap images to download") return } DispatchQueue.main.async { self.askUserToDownload(capImages: count) } } private func askUserToDownload(capImages: Int) { let detail = "\(capImages) caps have no image. Would you like to download them now? (\(ByteCountFormatter.string(fromByteCount: Int64(capImages * 10000), countStyle: .file)))" presentUserBinaryChoice("New classifier", detail: detail, yesText: "Download", noText: "Later") { self.downloadAllCapImages() } } private func askUserToDownload(classifier version: Int, size: Int64?) { let detail = "Version \(version) of the classifier is available for download (You have version \(app.database.classifierVersion)). Would you like to download it now?" let sizeText = size != nil ? " (\(ByteCountFormatter.string(fromByteCount: size!, countStyle: .file)))" : "" presentUserBinaryChoice("New classifier", detail: detail + sizeText, yesText: "Download") { self.downloadClassifier() } } 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.sync { 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", 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) alert.addAction(confirm) alert.addAction(cancel) self.present(alert, animated: true) } // MARK: Starting downloads private func downloadClassifier() { let hud = JGProgressHUD(style: .dark) hud.vibrancyEnabled = true hud.indicatorView = JGProgressHUDPieIndicatorView() hud.detailTextLabel.text = "0 % complete" hud.textLabel.text = "Downloading classifier" hud.show(in: self.view) app.database.downloadClassifier(progress: { progress, received, total in DispatchQueue.main.async { hud.progress = progress let t = ByteCountFormatter.string(fromByteCount: total, countStyle: .file) let r = ByteCountFormatter.string(fromByteCount: received, countStyle: .file) hud.detailTextLabel.text = String(format: "%.0f", progress * 100) + " % complete (\(r) / \(t))" } }) { success in DispatchQueue.main.async { hud.dismiss() self.didDownloadClassifier(successfully: success) } } } private func downloadServerDatabase() { let hud = JGProgressHUD(style: .dark) hud.vibrancyEnabled = true hud.indicatorView = JGProgressHUDPieIndicatorView() hud.detailTextLabel.text = "0 % complete" hud.textLabel.text = "Downloading server database" hud.show(in: self.view) app.database.downloadServerDatabase(progress: { progress, received, total in DispatchQueue.main.async { hud.progress = progress let t = ByteCountFormatter.string(fromByteCount: total, countStyle: .file) let r = ByteCountFormatter.string(fromByteCount: received, countStyle: .file) hud.detailTextLabel.text = String(format: "%.0f", progress) + " % complete (\(r) / \(t))" } }, completion: { success in guard success else { self.log("Failed to download server database") hud.detailTextLabel.text = "Download failed" hud.dismiss(afterDelay: 2.0) return } DispatchQueue.main.async { hud.textLabel.text = "Processing data" hud.progress = 0.2 hud.detailTextLabel.text = "Please wait..." } app.needsDownload = false }) { DispatchQueue.main.async { hud.dismiss() self.checkIfCapImagesNeedDownload() } } } private func downloadAllCapImages() { let hud = JGProgressHUD(style: .dark) hud.vibrancyEnabled = true hud.indicatorView = JGProgressHUDPieIndicatorView() hud.detailTextLabel.text = "0 % complete" hud.textLabel.text = "Downloading cap images" hud.show(in: self.view) app.database.downloadMainCapImages { (done, total) in let progress = Float(done) / Float(total) let percent = Int((progress * 100).rounded()) hud.detailTextLabel.text = "\(percent) % complete (\(done) / \(total))" hud.progress = progress if done >= total { hud.dismiss(afterDelay: 1.0) } } } // 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() } } } // MARK: Finishing downloads private func didDownloadClassifier(successfully success: Bool) { guard success else { self.log("Failed to download classifier") return } self.log("Classifier was downloaded.") guard let image = accessory!.currentImage else { 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 caps.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() } var sortControllerShouldIncludeMatchOption: Bool { matches != nil } } // 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] cell.set(cap: cap, match: match(for: cap.id)) return cell } 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, didSelectRowAt indexPath: IndexPath) { defer { tableView.deselectRow(at: indexPath, animated: true) } 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) { let generator = UIImpactFeedbackGenerator(style: style) generator.impactOccurred() } override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { 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 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] let count = UIContextualAction(style: .normal, title: "Update\ncount") { (_, _, success) in self.giveFeedback(.medium) success(true) DispatchQueue.global(qos: .userInitiated).async { app.database.download.imageCount(for: cap.id) { count in guard let count = count else { return } guard app.database.update(count: count, for: cap.id) else { return } // Delegate call will update the cell } } } count.backgroundColor = .orange let similar = UIContextualAction(style: .normal, title: "Similar\ncaps") { (_, _, success) in self.giveFeedback(.medium) self.accessory?.hideImageView() guard let image = cap.image else { success(false) return } self.classify(image: image) success(true) } similar.backgroundColor = .blue return UISwipeActionsConfiguration(actions: [similar, count]) } } // MARK: - Logging extension TableView: Logger { } // MARK: - Protocol DatabaseDelegate extension TableView: DatabaseDelegate { func database(didChangeCap id: Int) { updateNavigationItemTitleView() guard let cap = app.database.cap(for: id) else { return } if let index = caps.firstIndex(where: { $0.id == id }) { caps[index] = cap } if let index = shownCaps.firstIndex(where: { $0.id == id }) { shownCaps[index] = cap } let match = self.match(for: id) DispatchQueue.main.async { if let cell = self.tableView.visibleCells.first(where: { ($0 as! CapCell).id == id }) { (cell as! CapCell).set(cap: cap, match: match) } } } 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) } private func updateShownCaps(_ newList: [Cap], insertedId id: Int) { 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 } DispatchQueue.main.async { self.shownCaps = newList self.tableView.beginUpdates() let indexPath = IndexPath(row: index, section: 0) self.tableView.insertRows(at: [indexPath], with: .automatic) self.tableView.endUpdates() } } func databaseRequiresFullRefresh() { updateNavigationItemTitleView() reloadCapsFromDatabase() } } // 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) { saveNewCap(for: image) } func capAccessoryCameraButtonPressed() { showCameraView() } }