499 lines
19 KiB
Swift
499 lines
19 KiB
Swift
import UIKit
|
|
import RealmSwift
|
|
import SwiftDate
|
|
import PKHUD
|
|
import CoreLocation
|
|
import AutoCatCore
|
|
import SwiftLocation
|
|
|
|
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 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(forName: .NSCalendarDayChanged,
|
|
object: nil,
|
|
queue: .main,
|
|
using: calendarDayDidChange)
|
|
}
|
|
|
|
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)
|
|
Task { await self.handleQuickActions() }
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
func handleQuickActions() async {
|
|
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: [VehicleEventDto] = []
|
|
if let event = event {
|
|
events = [event]
|
|
action = .doNotSend
|
|
}
|
|
do {
|
|
HUD.show(.progress)
|
|
let (vehicle, errors) = try await self.check(number: number, action: action, notes: [], events: events)
|
|
if !vehicle.unrecognized {
|
|
self.updateDetailController(with: vehicle)
|
|
}
|
|
HUD.hide()
|
|
self.showErrors(errors)
|
|
} catch {
|
|
HUD.hide()
|
|
self.show(error: error)
|
|
}
|
|
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 {
|
|
Task { await sd.openReport(with: number) }
|
|
}
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
nonisolated private func calendarDayDidChange(_ notification : Notification) {
|
|
MainActor.assumeIsolated {
|
|
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)
|
|
|
|
#if targetEnvironment(macCatalyst)
|
|
self.save(file: tmpUrl)
|
|
#else
|
|
self.shareFile(tmpUrl)
|
|
#endif
|
|
} 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)
|
|
}
|
|
|
|
func save(file url: URL) {
|
|
if #available(iOS 14, *) {
|
|
let controller = UIDocumentPickerViewController(forExporting: [url])
|
|
self.present(controller, animated: true)
|
|
} else {
|
|
let controller = UIDocumentPickerViewController(url: url, in: .exportToService)
|
|
present(controller, animated: true)
|
|
}
|
|
}
|
|
|
|
// MARK: - Checking new number
|
|
|
|
func checkTapped(number: String) {
|
|
let numberNormalized = number.filter { !$0.isWhitespace }.uppercased()
|
|
|
|
var events: [VehicleEventDto] = []
|
|
do {
|
|
let realm = try Realm()
|
|
if let dbVehicle = realm.object(ofType: Vehicle.self, forPrimaryKey: numberNormalized) {
|
|
events.append(contentsOf: dbVehicle.events.map(\.dto))
|
|
}
|
|
} catch {
|
|
print(error)
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
HUD.show(.progress)
|
|
let (vehicle, errors) = try await self.check(number: numberNormalized,
|
|
action: .receiveAndSend,
|
|
notes: [],
|
|
events: events)
|
|
if !vehicle.unrecognized && errors.isEmpty {
|
|
self.updateDetailController(with: vehicle)
|
|
}
|
|
HUD.hide()
|
|
self.showErrors(errors)
|
|
} catch {
|
|
HUD.hide()
|
|
self.show(error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateDetailController(with vehicle: VehicleDto) {
|
|
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)
|
|
// }
|
|
|
|
Task {
|
|
let coordinator = ReportCoordinator(splitController: splitViewController, vehicle: vehicle)
|
|
try? await coordinator.start()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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: VehicleDto) {
|
|
Task {
|
|
do {
|
|
HUD.show(.progress)
|
|
let (vehicle, errors) = try await self.check(number: vehicle.getNumber(),
|
|
action: .doNotSend,
|
|
notes: Array(vehicle.notes),
|
|
events: Array(vehicle.events),
|
|
force: true)
|
|
if !vehicle.unrecognized {
|
|
self.updateDetailController(with: vehicle)
|
|
}
|
|
HUD.hide()
|
|
self.showErrors(errors)
|
|
} catch {
|
|
HUD.hide()
|
|
self.show(error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
func remove(vehicle: VehicleDto) {
|
|
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: VehicleDto) throws {
|
|
let realm = try Realm()
|
|
try realm.write {
|
|
realm.add(Vehicle(dto: vehicle), update: .all)
|
|
}
|
|
}
|
|
|
|
func getEvent(for action: EventAction) async throws -> VehicleEventDto {
|
|
if let event = await RxLocationManager.getLastEvent(), (Date().timeIntervalSince1970 - event.date) < 100 {
|
|
return event
|
|
} else {
|
|
return try await RxLocationManager.requestCurrentLocation()
|
|
}
|
|
}
|
|
|
|
func prepareEvent(for action: EventAction) async -> (event: VehicleEventDto?, error: Error?) {
|
|
guard action != .doNotSend else {
|
|
return (event: nil, error: nil)
|
|
}
|
|
|
|
do {
|
|
var event = try await getEvent(for: action)
|
|
event.address = try? await RxLocationManager.getAddressForLocation(latitude: event.latitude,
|
|
longitude: event.longitude)
|
|
return (event: event, error: nil)
|
|
} catch {
|
|
return (event: nil, error: error)
|
|
}
|
|
}
|
|
|
|
func checkVehicle(number: String,
|
|
notes: [VehicleNoteDto],
|
|
events: [VehicleEventDto],
|
|
force: Bool = false) async -> (vehicle: VehicleDto, error: Error?) {
|
|
|
|
do {
|
|
let vehicle = try await ApiService.shared.checkVehicle(by: number, notes: notes, events: events, force: force)
|
|
try self.save(vehicle: vehicle)
|
|
return (vehicle: vehicle, error: nil)
|
|
} catch {
|
|
let realm = try? await Realm()
|
|
if let existingVehicle = realm?.object(ofType: Vehicle.self, forPrimaryKey: number) {
|
|
return (vehicle: existingVehicle.dto, error: error)
|
|
} else {
|
|
let vehicle = Vehicle(number)
|
|
try? realm?.write { realm?.add(vehicle, update: .all) }
|
|
return (vehicle: vehicle.dto, error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
func check(number: String,
|
|
action: EventAction,
|
|
notes: [VehicleNoteDto],
|
|
events: [VehicleEventDto],
|
|
force: Bool = false) async throws -> (vehicle: VehicleDto, errors: [Error]) {
|
|
|
|
async let eventTask = prepareEvent(for: action)
|
|
async let vehicleTask = checkVehicle(number: number, notes: notes, events: events, force: force)
|
|
let (eventResult, vehicleResult) = await (eventTask, vehicleTask)
|
|
|
|
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 await 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(VehicleEvent(dto: event))
|
|
vehicle.updatedDate = Date().timeIntervalSince1970
|
|
vehicle.synchronized = false
|
|
}
|
|
}
|
|
|
|
if vehicleResult.error != nil {
|
|
return (vehicle: vehicleResult.vehicle, errors: errors)
|
|
} else {
|
|
if let event = eventResult.event {
|
|
do {
|
|
let vehicle = try await ApiService.shared.add(event: event, to: vehicleResult.vehicle.getNumber())
|
|
try self.save(vehicle: vehicle)
|
|
return (vehicle: vehicle, errors: errors)
|
|
} catch {
|
|
errors.append(error)
|
|
return (vehicle: vehicleResult.vehicle, errors: errors)
|
|
}
|
|
} else {
|
|
return (vehicle: vehicleResult.vehicle, errors: errors)
|
|
}
|
|
}
|
|
}
|
|
|
|
func showErrors(_ errors: [Error]) {
|
|
Task {
|
|
for error in errors {
|
|
await asyncShowError(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
func asyncShowError(_ error: Error) async {
|
|
await withCheckedContinuation { continuation in
|
|
self.show(error: error, animated: true) {
|
|
continuation.resume()
|
|
}
|
|
}
|
|
}
|
|
}
|