8892d04f62
- Add unlock - Update Sorting menu - Prepare to load multiple tile images - New logging - Calculate thumbnails and colors before schowing grid
1041 lines
35 KiB
Swift
1041 lines
35 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 {
|
|
|
|
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) {
|
|
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
|
|
|
|
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 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 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() {
|
|
guard classifier == nil 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()
|
|
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()
|
|
}
|
|
}
|