AutoCat/AutoCat/Controllers/CheckController.swift

453 lines
18 KiB
Swift

import UIKit
import RealmSwift
import RxSwift
import SwiftDate
import RxRealm
import PKHUD
import CoreLocation
import AutoCatCore
enum EventAction: Equatable {
case doNotSend
case receiveAndSend
}
enum HistoryFilter {
case all
case unrecognized
case outdated
}
extension String.StringInterpolation {
mutating func appendInterpolation(_ value: EventAction) {
switch value {
case .doNotSend: appendLiteral("do not send"); break
case .receiveAndSend: appendLiteral("receive and send"); break
}
}
}
class CheckController: UIViewController, UITableViewDelegate, UISearchResultsUpdating {
@IBOutlet weak var history: UITableView!
private let bag = DisposeBag()
private var historyDataSource: RealmSectionedDataSource<Vehicle, VehicleCell>!
private var historyFilter: HistoryFilter = .all
private lazy var searchController: UISearchController = .default
.placeholder(NSLocalizedString("Search plate numbers", comment: ""))
.resultsUpdater(self)
.makeDumb()
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.searchController = searchController
guard let realm = try? Realm() else { return }
self.hideKeyboardWhenTappedAround()
DispatchQueue.main.async {
self.historyDataSource = RealmSectionedDataSource(table: self.history, data: realm.objects(Vehicle.self).sorted(byKeyPath: "updatedDate", ascending: false)) { count in
self.navigationItem.title = String.localizedStringWithFormat(NSLocalizedString("vehicles found", comment: ""), count)
}
}
self.history.delegate = self
NotificationCenter.default.addObserver(self, selector:#selector(self.calendarDayDidChange(_:)), name:NSNotification.Name.NSCalendarDayChanged, object:nil)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if let index = self.history.indexPathForSelectedRow {
self.history.deselectRow(at: index, animated: true)
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.handleQuickActions()
}
// MARK: -
func handleQuickActions() {
guard let ad = UIApplication.shared.delegate as? AppDelegate else { return }
switch ad.quickAction {
case .check:
ad.quickAction = .none
if let tabBar = tabBarController as? MainTabController {
tabBar.showCheckPuller()
}
break
case .checkNumber(let number, let event):
ad.quickAction = .none
var action: EventAction = .receiveAndSend
var events: [VehicleEvent] = []
if let event = event {
events = [event]
action = .doNotSend
}
HUD.show(.progress)
self.check(number: number, action: action, notes: [], events: events).subscribe { (vehicle, errors) in
if !vehicle.unrecognized {
self.updateDetailController(with: vehicle)
}
HUD.hide()
self.showErrors(errors)
} onError: { error in
HUD.hide()
self.show(error: error)
//HUD.show(error: error)
}
.disposed(by: self.bag)
break
case .addVoiceRecord:
self.tabBarController?.selectedIndex = 1
break
case .openReport(let number):
ad.quickAction = .none
if let sd = self.view.window?.windowScene?.delegate as? SceneDelegate {
sd.openReport(with: number)
}
break
default:
break
}
}
@objc private func calendarDayDidChange(_ notification : NSNotification) {
DispatchQueue.main.async {
self.historyDataSource.reload()
}
}
// MARK: - Actions
@IBAction func onExport(_ sender: UIBarButtonItem) {
let sheet = UIAlertController(title: NSLocalizedString("Export history as", comment: ""), message: nil, preferredStyle: .actionSheet)
let cancel = UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel) { _ in sheet.dismiss(animated: true, completion: nil) }
let csv = UIAlertAction(title: NSLocalizedString("CSV table", comment: "export as CSV table"), style: .default) { action in
do {
let csvString = try self.historyDataSource.makeCsv()
let tmpUrl = FileManager.default.tmpUrl(name: "history", ext: "csv")
try csvString.write(to: tmpUrl, atomically: true, encoding: .utf8)
self.shareFile(tmpUrl)
} catch {
self.show(error: error)
}
}
let db = UIAlertAction(title: NSLocalizedString("Database file", comment: "export as database file"), style: .default) { action in
do {
let realm = try Realm()
let tmpUrl = FileManager.default.tmpUrl(name: "history", ext: "realm")
if let dbUrl = realm.configuration.fileURL {
try FileManager.default.copyItem(at: dbUrl, to: tmpUrl)
self.shareFile(tmpUrl)
}
} catch {
self.show(error: error)
}
}
sheet.addAction(csv)
sheet.addAction(db)
sheet.addAction(cancel)
sheet.popoverPresentationController?.barButtonItem = sender
self.present(sheet, animated: true)
}
@IBAction func onFilter(_ sender: UIBarButtonItem) {
let sheet = UIAlertController(title: NSLocalizedString("Filter check history", comment: ""), message: nil, preferredStyle: .actionSheet)
let cancel = UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel) { _ in sheet.dismiss(animated: true, completion: nil) }
let all = UIAlertAction(title: NSLocalizedString("All", comment: ""), style: .default) { action in
self.historyDataSource.setFilterPredicate(nil)
self.historyFilter = .all
}
let unrecognized = UIAlertAction(title: NSLocalizedString("Unrecognized", comment: ""), style: .default) { _ in
self.historyDataSource.setFilterPredicate { $0.unrecognized }
self.historyFilter = .unrecognized
}
let outdated = UIAlertAction(title: NSLocalizedString("Outdated", comment: ""), style: .default) { _ in
self.historyDataSource.setFilterPredicate { $0.outdated }
self.historyFilter = .outdated
}
switch self.historyFilter {
case .all: all.setValue(true, forKey: "checked")
case .unrecognized: unrecognized.setValue(true, forKey: "checked")
case .outdated: outdated.setValue(true, forKey: "checked")
}
sheet.addAction(all)
sheet.addAction(unrecognized)
sheet.addAction(outdated)
sheet.addAction(cancel)
sheet.popoverPresentationController?.barButtonItem = sender
self.present(sheet, animated: true)
}
func shareFile(_ url: URL) {
let activityController = UIActivityViewController(activityItems: [url], applicationActivities: nil)
self.present(activityController, animated: true)
}
// MARK: - Checking new number
func checkTapped(number: String) {
let numberNormalized = number.filter { !$0.isWhitespace }.uppercased()
var events: [VehicleEvent] = []
do {
let realm = try Realm()
if let dbVehicle = realm.object(ofType: Vehicle.self, forPrimaryKey: numberNormalized) {
events.append(contentsOf: dbVehicle.events.map { $0.clone() })
}
} catch {
print(error)
}
HUD.show(.progress)
self.check(number: numberNormalized, action: .receiveAndSend, notes: [], events: events).subscribe { (vehicle, errors) in
if !vehicle.unrecognized && errors.isEmpty {
self.updateDetailController(with: vehicle)
}
HUD.hide()
self.showErrors(errors)
} onError: { error in
HUD.hide()
self.show(error: error)
}
.disposed(by: self.bag)
}
func updateDetailController(with vehicle: Vehicle) {
if let splitViewController = self.view.window?.rootViewController as? UISplitViewController
{
var detail: UINavigationController?
if splitViewController.viewControllers.count == 2 {
detail = splitViewController.viewControllers.last as? UINavigationController
} else {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
detail = storyboard.instantiateViewController(identifier: "ReportNavController")
}
if let detail = detail {
detail.popToRootViewController(animated: true)
let report = detail.viewControllers.first as? ReportController
report?.number = vehicle.getNumber()
splitViewController.showDetailViewController(detail, sender: self)
//self.performSegue(withIdentifier: "OpenDetailSegue", sender: self)
}
}
}
// MARK: - UITableViewDelegate
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let vehicle = self.historyDataSource.item(at: indexPath)
let updateAction = UIContextualAction(style: .normal, title: NSLocalizedString("Update", comment: "")) { action, view, completion in
self.update(vehicle: vehicle)
completion(true)
}
updateAction.image = UIImage(systemName: "arrow.2.circlepath")
updateAction.backgroundColor = .systemBlue
let removeAction = UIContextualAction(style: .destructive, title: NSLocalizedString("Remove", comment: "")) { action, view, completion in
self.remove(vehicle: vehicle)
}
removeAction.image = UIImage(systemName: "trash")
let configuration = UISwipeActionsConfiguration(actions: [updateAction, removeAction])
configuration.performsFirstActionWithFullSwipe = false
return configuration
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let vehicle = self.historyDataSource.item(at: indexPath)
self.updateDetailController(with: vehicle)
}
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
let vehicle = self.historyDataSource.item(at: indexPath)
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
let update = UIAction(title: NSLocalizedString("Update", comment: ""), image: UIImage(systemName: "arrow.2.circlepath")) { action in
self.update(vehicle: vehicle)
}
let remove = UIAction(title: NSLocalizedString("Remove", comment: ""), image: UIImage(systemName: "trash"), attributes: [.destructive]) { action in
self.remove(vehicle: vehicle)
}
return UIMenu(title: NSLocalizedString("Actions", comment: ""), children: [update, remove])
}
}
// MARK: - UISearchResultsUpdating
func updateSearchResults(for searchController: UISearchController) {
guard let text = searchController.searchBar.text?.uppercased(),
!text.isEmpty
else {
historyDataSource.setSearchPredicate(nil)
return
}
let regex = try? NSRegularExpression(pattern: text)
historyDataSource.setSearchPredicate { vehicle in
let number = vehicle.getNumber()
if let regex {
let range = NSRange(location: 0, length: number.utf16.count)
return regex.numberOfMatches(in: number, range: range) > 0
} else {
return number.contains(text)
}
}
}
// MARK: - Contextual actions
func update(vehicle: Vehicle) {
HUD.show(.progress)
self.check(number: vehicle.getNumber(), action: .doNotSend, notes: Array(vehicle.notes), events: Array(vehicle.events), force: true).subscribe { (vehicle, errors) in
if !vehicle.unrecognized {
self.updateDetailController(with: vehicle)
}
HUD.hide()
self.showErrors(errors)
} onError: { error in
HUD.hide()
self.show(error: error)
}
.disposed(by: self.bag)
}
func remove(vehicle: Vehicle) {
guard let realm = try? Realm() else { return }
guard let realmVehicle = realm.object(ofType: Vehicle.self, forPrimaryKey: vehicle.getNumber()) else { return }
do {
try realm.write {
realm.delete(realmVehicle)
}
} catch {
print(error)
}
}
// MARK: - Checking number
func save(vehicle: Vehicle) throws {
let realm = try Realm()
try realm.write {
realm.add(vehicle, update: .all)
}
}
func getEvent(for action: EventAction) -> Single<VehicleEvent> {
if let event = RxLocationManager.lastEvent, (Date().timeIntervalSince1970 - event.date) < 100 {
return Single<VehicleEvent>.just(event)
} else {
return RxLocationManager.requestCurrentLocation()
}
}
func check(number: String, action: EventAction, notes: [VehicleNote], events: [VehicleEvent], force: Bool = false) -> Single<(vehicle: Vehicle, errors: [Error])> {
var eventSingle: Single<(event: VehicleEvent?, error: Error?)> = .just((event: nil, error: nil))
if action != .doNotSend {
eventSingle = self.getEvent(for: action)
.flatMap { event in event.findAddress().map{ event }.catchErrorJustReturn(event) }
.map { event -> (event: VehicleEvent?, error: Error?) in (event: event, error: nil) }
.observeOn(MainScheduler.instance)
.catchError { .just((event: nil, error: $0)) }
}
let checkSingle = Api.checkVehicle(by: number, notes: notes, events: events, force: force)
.observeOn(MainScheduler.instance)
.map { (vehicle: Vehicle) -> (vehicle: Vehicle, error: Error?) in
try self.save(vehicle: vehicle)
return (vehicle: vehicle, error: nil)
}
.catchError { error in
let realm = try Realm()
if let existingVehicle = realm.object(ofType: Vehicle.self, forPrimaryKey: number) {
return .just((vehicle: existingVehicle, error: error))
} else {
let vehicle = Vehicle(number)
try realm.write { realm.add(vehicle, update: .all) }
return .just((vehicle: vehicle, error: error))
}
}
return Single.zip(eventSingle, checkSingle).flatMap { eventResult, vehicleResult in
var errors = [eventResult.error, vehicleResult.error].map { error -> Error? in
if let clerror = error as? CLError {
if clerror.code != .denied {
return CocoaError.error(NSLocalizedString("Location error", comment: ""), reason: clerror.code.description)
} else {
return nil
}
} else {
return error
}
}
.compactMap { $0 }
RxLocationManager.resetLastEvent()
let realm = try Realm()
let dbVehicle = realm.object(ofType: Vehicle.self, forPrimaryKey: vehicleResult.vehicle.getNumber())
if let event = eventResult.event, let vehicle = dbVehicle {
try realm.write {
vehicle.events.append(event)
vehicle.updatedDate = Date().timeIntervalSince1970
vehicle.synchronized = false
}
}
if vehicleResult.error != nil {
return .just((vehicle: vehicleResult.vehicle, errors: errors))
} else {
if let event = eventResult.event {
return Api.add(event: event, to: vehicleResult.vehicle.getNumber())
.observeOn(MainScheduler.instance)
.map {
try self.save(vehicle: $0)
return (vehicle: $0, errors: errors)
}
.catchError { error in
errors.append(error)
return .just((vehicle: vehicleResult.vehicle, errors: errors))
}
} else {
return .just((vehicle: vehicleResult.vehicle, errors: errors))
}
}
}
}
func showErrors(_ errors: [Error]) {
let observables = errors.map(rxShowError)
Observable.from(observables).concat().subscribe().disposed(by: self.bag)
}
func rxShowError(_ error: Error) -> Observable<Void> {
return Observable<Void>.create { observer in
self.show(error: error, animated: true) {
observer.on(.next(()))
observer.on(.completed)
}
return Disposables.create()
}
}
}