// // TableView.swift // CapFinder // // Created by User on 22.04.18. // Copyright © 2018 User. All rights reserved. // import UIKit import JGProgressHUD class TableView: UITableViewController { @IBOutlet weak var infoButton: UIBarButtonItem! 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? private var isUnlocked = false private var processingScreenHud: JGProgressHUD? // 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, !app.database.isInOfflineMode else { showOfflineDialog() return } downloadCapNames() } @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") downloadCapNames() showProcessingScreen() } else { log("Loaded \(count) caps") reloadCapsFromDatabase() loadClassifier() } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) (navigationController as? NavigationController)?.allowLandscape = false isUnlocked = app.isUnlocked log(isUnlocked ? "App is unlocked" : "App is locked") } 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 updateNavigationItemTitleView() { DispatchQueue.main.async { self.titleLabel.text = self.titleText self.subtitleLabel.text = self.subtitleText } } // MARK: Starting updates private func checkThumbnailsAndColorsBeforShowingGrid() { let missingImageCount = app.database.capCountWithoutImages guard missingImageCount == 0 else { askUserToDownload(capImages: missingImageCount) return } createMissingThumbnailsBeforeShowingGrid() } private func createMissingThumbnailsBeforeShowingGrid() { let missing = app.database.capsWithoutThumbnails.map { $0.id } guard missing.count > 0 else { log("No thumbnails missing, checking colors") checkColorsBeforeShowingGrid() return } log("Generating \(missing.count) thumbnails") let hud = JGProgressHUD(style: traitCollection.userInterfaceStyle == .dark ? .dark : .light) hud.indicatorView = JGProgressHUDPieIndicatorView() hud.detailTextLabel.text = "0 % complete (0 / \(missing.count)" hud.textLabel.text = "Generating thumbnails" hud.show(in: self.view) let group = DispatchGroup() var done = 0 let split = 50 DispatchQueue.global(qos: .background).async { for part in missing.split(intoPartsOf: split) { for id in part { group.enter() defer { done += 1 let ratio = Float(done) / Float(missing.count) let percent = Int((ratio * 100).rounded()) DispatchQueue.main.async { hud.progress = ratio hud.detailTextLabel.text = "\(percent) % complete (\(done) / \(missing.count))" } group.leave() } guard let image = app.storage.image(for: id) else { return } let thumbnail = Cap.thumbnail(for: image) app.storage.save(thumbnail: thumbnail, for: id) } if group.wait(timeout: .now() + .seconds(30)) != .success { self.log("Timed out waiting for thumbnails to be generated") } } DispatchQueue.main.async { hud.dismiss() self.checkColorsBeforeShowingGrid() } } } private func checkColorsBeforeShowingGrid() { let missing = Array(app.database.capsWithoutColors) guard missing.count > 0 else { log("No missing colors, showing grid") showGrid() return } log("Generating \(missing.count) colors") let hud = JGProgressHUD(style: traitCollection.userInterfaceStyle == .dark ? .dark : .light) hud.indicatorView = JGProgressHUDPieIndicatorView() hud.detailTextLabel.text = "0 % complete (0 / \(missing.count)" hud.textLabel.text = "Generating colors" hud.show(in: self.view) let group = DispatchGroup() var done = 0 let split = 50 let context = CIContext(options: [.workingColorSpace: kCFNull!]) DispatchQueue.global(qos: .background).async { for part in missing.split(intoPartsOf: split) { for id in part { group.enter() defer { done += 1 let ratio = Float(done) / Float(missing.count) let percent = Int((ratio * 100).rounded()) DispatchQueue.main.async { hud.progress = ratio hud.detailTextLabel.text = "\(percent) % complete (\(done) / \(missing.count))" } group.leave() } guard let image = app.storage.ciImage(for: id) else { return } guard let color = image.averageColor(context: context) else { return } app.database.set(color: color, for: id) } if group.wait(timeout: .now() + .seconds(30)) != .success { self.log("Timed out waiting for colors to be generated") } context.clearCaches() } DispatchQueue.main.async { hud.dismiss() self.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.uploadRemainingData() 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 downloadCapNames() { app.database.downloadCapNames { success in guard success else { self.hideProcessingScreen() self.showAlert("Failed to download cap names", title: "Sync failed") return } self.downloadImageCounts() } } private func downloadImageCounts() { app.database.downloadImageCounts { success in guard success else { self.hideProcessingScreen() self.showAlert("Failed to download image counts", title: "Sync failed") return } self.hideProcessingScreen() self.checkIfCapImagesNeedDownload() } } private func checkIfCapImagesNeedDownload() { let count = app.database.capCountWithoutImages guard count > 0 else { log("No cap images to download") self.downloadNewestClassifierIfNeeded() return } DispatchQueue.main.async { self.askUserToDownload(capImages: count) } } 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 { 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 private func showProcessingScreen() { guard processingScreenHud == nil else { log("Already showing processing screen") return } let style: JGProgressHUDStyle = traitCollection.userInterfaceStyle == .dark ? .dark : .extraLight let hud = JGProgressHUD(style: style) hud.indicatorView = JGProgressHUDIndeterminateIndicatorView() hud.detailTextLabel.text = "Please wait until the app has finished processing." hud.textLabel.text = "Processing..." hud.show(in: self.view) self.processingScreenHud = hud } private func hideProcessingScreen() { processingScreenHud?.dismiss() processingScreenHud = nil } @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(reload: Bool = false) { guard classifier == nil || reload else { return } guard let model = app.storage.recognitionModel else { downloadNewestClassifierIfNeeded() return } classifier = Classifier(model: model) } 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("Download images", detail: detail, yesText: "Download", noText: "Later", dismissed: { self.downloadNewestClassifierIfNeeded() }) { self.downloadAllCapImages() } } private func askUserToDownload(classifier version: Int, size: Int64?) { let oldVersion = app.database.classifierVersion let sizeText = size != nil ? " (\(ByteCountFormatter.string(fromByteCount: size!, countStyle: .file)))" : "" guard oldVersion > 0 else { askUserToDownloadFirst(classifier: version, sizeText: sizeText) return } askUserToDownloadNew(classifier: version, sizeText: sizeText, oldVersion: oldVersion) } private func askUserToDownloadNew(classifier version: Int, sizeText: String, oldVersion: Int) { let detail = "Version \(version) of the classifier is available for download (You have version \(oldVersion)). Would you like to download it now?" presentUserBinaryChoice("New classifier", detail: detail + sizeText, yesText: "Download") { self.downloadClassifier() } } private func askUserToDownloadFirst(classifier version: Int, sizeText: String) { let detail = "A classifier to match caps is available for download (version \(version). Would you like to download it now?" presentUserBinaryChoice("Download 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.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) self.present(alert, animated: true) } // MARK: Starting downloads private func downloadClassifier() { let style: JGProgressHUDStyle = traitCollection.userInterfaceStyle == .dark ? .dark : .light let hud = JGProgressHUD(style: style) //hud.vibrancyEnabled = true hud.indicatorView = JGProgressHUDPieIndicatorView() hud.detailTextLabel.text = "0 % complete" hud.textLabel.text = "Downloading image 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) + " % (\(r) / \(t))" } }) { success in DispatchQueue.main.async { hud.dismiss() self.didDownloadClassifier(successfully: success) } } } private func downloadAllCapImages() { let style: JGProgressHUDStyle = traitCollection.userInterfaceStyle == .dark ? .dark : .light let hud = JGProgressHUD(style: style) //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) % (\(done) / \(total))" hud.progress = progress if done >= total { hud.dismiss(afterDelay: 1.0) self.downloadNewestClassifierIfNeeded() } } } // 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(successfully success: Bool) { guard success else { self.log("Failed to download classifier") return } loadClassifier(reload: true) self.log("Classifier was downloaded.") 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, isUnlocked: isUnlocked) 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 = app.storage.image(for: cap.id) { cell.set(image: image) } else { cell.set(image: nil) app.database.downloadMainImage(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 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 = app.storage.image(for: cap.id) 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(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) { guard let cell = visibleCell(for: cap) else { return } guard let image = app.storage.image(for: cap) else { 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) { saveNewCap(for: image) } func capAccessoryCameraButtonPressed() { showCameraView() } }