886 lines
28 KiB
Swift
886 lines
28 KiB
Swift
//
|
|
// 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()
|
|
}
|
|
|
|
}
|