Caps-iOS/CapCollector/TableView.swift
2020-09-09 18:57:56 +02:00

1073 lines
36 KiB
Swift

//
// 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()
}
}