Caps-iOS/Caps/TableView.swift
2022-04-28 15:54:13 +02:00

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