New project

This commit is contained in:
Selim Mustafaev 2022-03-25 19:51:57 +03:00
parent 1752d89b78
commit d023c60f54
88 changed files with 2717 additions and 2015 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
AutoCat2.xcodeproj/xcuserdata/ AutoCat2.xcodeproj/xcuserdata/
AutoCat2.xcodeproj/project.xcworkspace/xcuserdata/
.DS_Store .DS_Store

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
{
"pins" : [
{
"identity" : "pkhud",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pkluz/PKHUD.git",
"state" : {
"revision" : "8fd26f23057c6bebd6695524b1c3e05e93aba571",
"version" : "5.4.0"
}
},
{
"identity" : "swiftentrykit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/huri000/SwiftEntryKit",
"state" : {
"revision" : "5ad36cccf0c4b9fea32f4e9b17a8e38f07563ef0",
"version" : "2.0.0"
}
}
],
"version" : 2
}

View File

@ -4,39 +4,16 @@
<dict> <dict>
<key>SchemeUserState</key> <key>SchemeUserState</key>
<dict> <dict>
<key>AutoCat2 (iOS).xcscheme_^#shared#^_</key> <key>AutoCat2.xcscheme</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>0</integer> <integer>0</integer>
</dict> </dict>
<key>AutoCat2 (macOS).xcscheme_^#shared#^_</key> <key>AutoCatCore.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>1</integer> <integer>1</integer>
</dict> </dict>
<key>AutoCatCore.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>7A40D5822691C6D8009B0BC4</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>7A40D58D2691C6D8009B0BC4</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>7A40D5F22693A63A009B0BC4</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict> </dict>
</dict> </dict>
</plist> </plist>

View File

@ -0,0 +1,36 @@
//
// AppDelegate.swift
// AutoCat2
//
// Created by Selim Mustafaev on 05.03.2022.
//
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
return true
}
// MARK: UISceneSession Lifecycle
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
// Called when the user discards a scene session.
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
}

View File

@ -89,56 +89,6 @@
"idiom" : "ios-marketing", "idiom" : "ios-marketing",
"scale" : "1x", "scale" : "1x",
"size" : "1024x1024" "size" : "1024x1024"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
} }
], ],
"info" : { "info" : {

View File

@ -2,13 +2,8 @@
"colors" : [ "colors" : [
{ {
"color" : { "color" : {
"color-space" : "srgb", "platform" : "ios",
"components" : { "reference" : "systemGray2Color"
"alpha" : "1.000",
"blue" : "0.000",
"green" : "0.000",
"red" : "0.000"
}
}, },
"idiom" : "universal" "idiom" : "universal"
}, },
@ -20,12 +15,12 @@
} }
], ],
"color" : { "color" : {
"color-space" : "display-p3", "color-space" : "srgb",
"components" : { "components" : {
"alpha" : "1.000", "alpha" : "1.000",
"blue" : "0.866", "blue" : "0.290",
"green" : "0.866", "green" : "0.282",
"red" : "0.866" "red" : "0.282"
} }
}, },
"idiom" : "universal" "idiom" : "universal"

View File

@ -20,12 +20,12 @@
} }
], ],
"color" : { "color" : {
"color-space" : "display-p3", "color-space" : "srgb",
"components" : { "components" : {
"alpha" : "1.000", "alpha" : "0.300",
"blue" : "0.152", "blue" : "1.000",
"green" : "0.152", "green" : "1.000",
"red" : "0.152" "red" : "1.000"
} }
}, },
"idiom" : "universal" "idiom" : "universal"

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
</document>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="ViewController" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

@ -0,0 +1,84 @@
//
// Created by Selim Mustafaev on 06.03.2022.
//
import UIKit
enum ACButtonStyle {
case generic
case roundedBlue
}
class ACButton: UIButton {
private var style: ACButtonStyle = .generic
convenience init(style: ACButtonStyle = .roundedBlue, title: String, onTap: @escaping () -> Void) {
self.init()
self.style(style)
self.onTap(onTap)
self.title(title)
translatesAutoresizingMaskIntoConstraints = false
}
convenience init(style: ACButtonStyle = .roundedBlue, title: String, onTapAsync: @escaping () async -> Void) {
self.init()
self.style(style)
self.onTapAsync(onTapAsync)
self.title(title)
translatesAutoresizingMaskIntoConstraints = false
}
override func layoutSubviews() {
super.layoutSubviews()
if style == .roundedBlue {
self.layer.opacity = self.isEnabled ? 1 : 0.5
} else {
self.layer.opacity = 1
}
}
@discardableResult
func style(_ style: ACButtonStyle) -> ACButton {
self.style = style
switch style {
case .generic:
break
case .roundedBlue:
backgroundColor = .systemBlue
layer.cornerRadius = 6
}
return self
}
@discardableResult
func title(_ title: String) -> ACButton {
setTitle(title, for: .normal)
return self
}
@discardableResult
func enable(_ enabled: Bool) -> ACButton {
isEnabled = enabled
return self
}
@discardableResult
func onTap(_ handler: @escaping () -> Void) -> ACButton {
addAction(for: .touchUpInside, handler)
return self
}
@discardableResult
func onTapAsync(_ handler: @escaping () async -> Void) -> ACButton {
addActionAsync(for: .touchUpInside, handler)
return self
}
}

View File

@ -0,0 +1,82 @@
//
// ACTabBarButton.swift
// AutoCat2
//
// Created by Selim Mustafaev on 20.03.2022.
//
import UIKit
class ACTabBarButton: UIView {
enum State {
case normal
case selected
}
private var state: State = .normal
private var tabBarItem: UITabBarItem?
// MARK: - Views
private lazy var imageView: UIImageView = {
let view = UIImageView()
view.contentMode = .scaleAspectFit
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = UIFont.preferredFont(forTextStyle: .caption2)
label.textColor = tintColor
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var stackView: UIStackView = {
let stack = UIStackView(arrangedSubviews: [imageView, titleLabel])
stack.axis = .vertical
stack.spacing = 0
stack.distribution = .fill
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
// MARK: - Lifecycle
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
convenience init(tabBarItem: UITabBarItem) {
self.init(frame: .zero)
apply(tabBarItem: tabBarItem)
}
func setup() {
addSubview(stackView)
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
stackView.topAnchor.constraint(equalTo: topAnchor, constant: 4),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
imageView.widthAnchor.constraint(equalToConstant: 32),
imageView.heightAnchor.constraint(equalToConstant: 32)
])
}
func apply(tabBarItem: UITabBarItem) {
self.tabBarItem = tabBarItem
imageView.contentMode = .scaleAspectFit
imageView.image = state == .normal ? tabBarItem.image : tabBarItem.selectedImage
titleLabel.text = tabBarItem.title
}
}

View File

@ -0,0 +1,67 @@
//
// ACTabBarController.swift
// AutoCat2
//
// Created by Selim Mustafaev on 20.03.2022.
//
import UIKit
class ACTabBarController: UIViewController {
// MARK: - Properties
var viewControllers: [UIViewController] = [] {
didSet {
setupControllers()
}
}
// MARK: - Views
private lazy var buttonsStackView: UIStackView = {
let stack = UIStackView()
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .horizontal
stack.spacing = 0
stack.distribution = .fillEqually
return stack
}()
private let tabBarView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
// tabBarView.backgroundColor = .green
// buttonsStackView.backgroundColor = .magenta
view.addSubview(tabBarView)
tabBarView.addSubview(buttonsStackView)
NSLayoutConstraint.activate([
tabBarView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tabBarView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tabBarView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
buttonsStackView.leadingAnchor.constraint(equalTo: tabBarView.leadingAnchor),
buttonsStackView.trailingAnchor.constraint(equalTo: tabBarView.trailingAnchor),
buttonsStackView.topAnchor.constraint(equalTo: tabBarView.topAnchor),
buttonsStackView.bottomAnchor.constraint(equalTo: tabBarView.safeAreaLayoutGuide.bottomAnchor)
])
}
func setupControllers() {
buttonsStackView.removeAllArrangedSubviews()
for controller in viewControllers {
let button = ACTabBarButton(tabBarItem: controller.tabBarItem)
buttonsStackView.addArrangedSubview(button)
}
}
}

View File

@ -0,0 +1,98 @@
//
// Created by Selim Mustafaev on 06.03.2022.
//
import UIKit
enum ACTextFieldStyle {
case generic
case roundBordered
}
class ACTextField: UITextField, UITextFieldDelegate {
private var style: ACTextFieldStyle = .generic
private var onTextChanged: ((String?) -> Void)?
var editable: Bool = true
// MARK: - Lifecycle
convenience init(placeholder: String? = nil, style: ACTextFieldStyle = .roundBordered) {
self.init(frame: .zero)
self.delegate = self
self.placeholderText(placeholder)
self.style(style)
addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
translatesAutoresizingMaskIntoConstraints = false
}
override func layoutSubviews() {
super.layoutSubviews()
style(style)
}
// MARK: - Setters
@discardableResult
func placeholderText(_ placeholder: String?) -> ACTextField {
self.placeholder = placeholder
return self
}
@discardableResult
func keyboardType(_ keyboardType: UIKeyboardType) -> ACTextField {
self.keyboardType = keyboardType
return self
}
@discardableResult
func style(_ style: ACTextFieldStyle) -> ACTextField {
self.style = style
borderStyle = .roundedRect
switch style {
case .generic:
layer.borderWidth = 0
layer.cornerRadius = 0
layer.borderColor = nil
break
case .roundBordered:
layer.borderWidth = 1
layer.cornerRadius = 6
layer.borderColor = UIColor.secondaryLabel.cgColor
}
return self
}
@discardableResult
func secure(_ secure: Bool) -> ACTextField {
isSecureTextEntry = secure
return self
}
@discardableResult
func onTextChanged(_ handler: @escaping (String?) -> Void) -> ACTextField {
onTextChanged = handler
return self
}
// MARK: - UITextFieldDelegate
func textField(_ textField: UITextField,
shouldChangeCharactersIn range: NSRange,
replacementString string: String) -> Bool {
editable
}
@objc func textFieldDidChange(_ textField: UITextField) {
onTextChanged?(textField.text)
}
}

View File

@ -0,0 +1,23 @@
//
// Created by Selim Mustafaev on 06.03.2022.
//
import UIKit
extension UIControl {
func addAction(for controlEvents: UIControl.Event = .touchUpInside, _ closure: @escaping () -> Void) {
addAction(UIAction { _ in closure() }, for: controlEvents)
}
func addActionAsync(for controlEvents: UIControl.Event = .touchUpInside, _ closure: @escaping () async -> Void) {
addAction(UIAction { _ in
Task {
await closure()
}
}, for: controlEvents)
}
}

View File

@ -0,0 +1,26 @@
//
// UIStackView.swift
// AutoCat2
//
// Created by Selim Mustafaev on 20.03.2022.
//
import UIKit
extension UIStackView {
func removeAllArrangedSubviews() {
let removedSubviews = arrangedSubviews.reduce([]) { (allSubviews, subview) -> [UIView] in
self.removeArrangedSubview(subview)
return allSubviews + [subview]
}
// Deactivate all constraints
NSLayoutConstraint.deactivate(removedSubviews.flatMap({ $0.constraints }))
// Remove the views from self
removedSubviews.forEach({ $0.removeFromSuperview() })
}
}

View File

@ -0,0 +1,18 @@
//
// Created by Selim Mustafaev on 06.03.2022.
//
import UIKit
import Combine
extension UITextField {
var textPublisher: AnyPublisher<String?, Never> {
NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: self)
.map { ($0.object as? UITextField)?.text }
.eraseToAnyPublisher()
}
}

View File

@ -0,0 +1,25 @@
//
// UIView.swift
// AutoCat2
//
// Created by Selim Mustafaev on 20.03.2022.
//
import UIKit
extension UIView {
func disableTranslatesAutoresizingMaskIntoConstraints() -> Self {
translatesAutoresizingMaskIntoConstraints = false
return self
}
func pin(to view: UIView, insets: UIEdgeInsets = .zero) {
NSLayoutConstraint.activate([
leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: insets.left),
trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -insets.right),
topAnchor.constraint(equalTo: view.topAnchor, constant: insets.top),
bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -insets.bottom)
])
}
}

View File

@ -0,0 +1,16 @@
//
// Created by Selim Mustafaev on 10.03.2022.
//
import UIKit
extension UIViewController {
func show(error: Error) {
let alert = UIAlertController(title: nil, message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
}

View File

@ -0,0 +1,216 @@
import UIKit
import AutoCatCore
protocol PNKeyboardDelegate: AnyObject {
func returnClicked()
}
protocol PNButtonDelegate: AnyObject {
func buttonTapped(_ button: PNButton)
}
enum PNButtonType {
case symbol(String)
case backspace
case done
}
class PNButton: UIButton {
private(set) var type: PNButtonType
private var timer: Timer?
private var waitCount = 0
private var rectLayer = CAShapeLayer()
private var bgColor = UIColor(named: "KeyBackground")
weak var delegate: PNButtonDelegate?
init(letter: Character) {
self.type = .symbol(String(letter))
super.init(frame: .zero)
self.setup()
self.titleLabel?.font = UIFont(name: "RoadNumbers", size: 36)
let title = String(Constants.pnLettersMap[letter] ?? letter)
self.setTitle(title, for: .normal)
self.setTitleColor(.label, for: .normal)
}
init(digit: Int) {
self.type = .symbol(String(digit))
super.init(frame: .zero)
self.setup()
self.titleLabel?.font = UIFont(name: "RoadNumbersCyr-Regular", size: 30)
let character = Character(String(digit))
let title = String(Constants.pnLettersMap[character] ?? character)
self.setTitle(title, for: .normal)
self.setTitleColor(.label, for: .normal)
}
init(imageName: String, type: PNButtonType) {
self.type = type
self.bgColor = UIColor(named: "DarkKeyBackground")
super.init(frame: .zero)
self.setup()
self.setImage(UIImage(systemName: imageName), for: .normal)
self.tintColor = .label
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setup(_ radius: CGFloat = 0.5) {
self.rectLayer.cornerRadius = 6
self.layer.addSublayer(self.rectLayer)
self.imageView?.layer.zPosition = 2
self.rectLayer.shadowColor = UIColor.black.cgColor
self.rectLayer.shadowOpacity = 0.4
self.rectLayer.shadowOffset = CGSize(width: 0.0, height : 1.0)
self.rectLayer.shadowRadius = radius
self.addTarget(self, action: #selector(buttonDown), for: .touchDown)
self.addTarget(self, action: #selector(buttonUp), for: [.touchUpInside, .touchUpOutside])
self.addTarget(self, action: #selector(touchUpInside), for: .touchUpInside)
}
override func layoutSubviews() {
super.layoutSubviews()
self.rectLayer.backgroundColor = self.bgColor?.cgColor
self.rectLayer.frame = self.layer.bounds.inset(by: UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4))
}
@objc func buttonDown(_ sender: PNButton) {
sender.layer.opacity = 0.3
self.timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { _ in
self.waitCount += 1
if self.waitCount > 5 {
self.delegate?.buttonTapped(self)
}
})
}
@objc func buttonUp(_ sender: PNButton) {
sender.layer.opacity = 1
self.timer?.invalidate()
self.timer = nil
self.waitCount = 0
}
@objc func touchUpInside(_ sender: PNButton) {
self.delegate?.buttonTapped(self)
}
func setBacgroundColor(color: UIColor) {
self.bgColor = color
self.setNeedsLayout()
}
}
class PNKeyboard: UIView, UIInputViewAudioFeedback, PNButtonDelegate {
private weak var target: UIKeyInput?
weak var delegate: PNKeyboardDelegate?
private let insets: UIEdgeInsets
init(target: UIKeyInput?, insets: UIEdgeInsets) {
self.target = target
self.insets = insets
super.init(frame: .zero)
self.autoresizingMask = [.flexibleWidth, .flexibleHeight]
let blurEffectView = UIVisualEffectView(effect: UIBlurEffect())
blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.addSubview(blurEffectView)
self.setupButtons()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setupButtons() {
let letters: [PNButton] = Constants.pnLettersMap.keys.sorted().map { letter in
let button = PNButton(letter: letter)
button.delegate = self
return button
}
let digits: [PNButton] = [1,2,3,4,5,6,7,8,9,0].map { digit in
let button = PNButton(digit: digit)
button.delegate = self
return button
}
let backspace = PNButton(imageName: "delete.left", type: .backspace)
backspace.delegate = self
let done = PNButton(imageName: "arrow.turn.down.left", type: .done)
done.delegate = self
done.setBacgroundColor(color: .systemBlue)
done.tintColor = .white
let letterRows = [
self.createLetterStack([letters[0], letters[1], letters[2]]),
self.createLetterStack([letters[3], letters[4], letters[5]]),
self.createLetterStack([letters[6], letters[7], letters[8]]),
self.createLetterStack([letters[9], letters[10], letters[11]])
]
let lettersStack = UIStackView(arrangedSubviews: letterRows)
lettersStack.axis = .vertical
lettersStack.distribution = .fillEqually
//lettersStack.spacing = 8
let digitRows = [
self.createLetterStack([digits[0], digits[1], digits[2]]),
self.createLetterStack([digits[3], digits[4], digits[5]]),
self.createLetterStack([digits[6], digits[7], digits[8]]),
self.createLetterStack([digits[9], backspace, done])
]
let digitsStack = UIStackView(arrangedSubviews: digitRows)
digitsStack.axis = .vertical
digitsStack.distribution = .fillEqually
//digitsStack.spacing = 8
let mainStack = UIStackView(arrangedSubviews: [lettersStack, digitsStack])
mainStack.spacing = 16
mainStack.distribution = .fillEqually
mainStack.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(mainStack)
NSLayoutConstraint.activate([
mainStack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: insets.left),
mainStack.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -insets.right),
mainStack.topAnchor.constraint(equalTo: self.topAnchor, constant: insets.top),
mainStack.bottomAnchor.constraint(equalTo: self.safeAreaLayoutGuide.bottomAnchor, constant: -insets.bottom)
])
}
func createLetterStack(_ views: [UIView]) -> UIStackView {
let stack = UIStackView(arrangedSubviews: views)
stack.distribution = .fillEqually
//stack.spacing = 8
return stack
}
// MARK: - PNButtonDelegate
func buttonTapped(_ button: PNButton) {
UIDevice.current.playInputClick()
switch button.type {
case .symbol(let s):
self.target?.insertText(s)
case .backspace:
self.target?.deleteBackward()
case .done:
self.delegate?.returnClicked()
}
}
// MARK: - UIInputViewAudioFeedback
var enableInputClicksWhenVisible: Bool {
return true
}
}

View File

@ -0,0 +1,31 @@
import UIKit
class FlagLayer: CALayer {
let pixelWidth = 1/UIScreen.main.scale
// Flag colors - https://ru.wikipedia.org/wiki/%D0%A4%D0%BB%D0%B0%D0%B3_%D0%A0%D0%BE%D1%81%D1%81%D0%B8%D0%B8
let blue = CGColor(srgbRed: 0, green: 57/256.0, blue: 166/256.0, alpha: 1)
let red = CGColor(srgbRed: 213/256.0, green: 43/256.0, blue: 30/256.0, alpha: 1)
override func draw(in ctx: CGContext) {
ctx.saveGState()
super.draw(in: ctx)
ctx.setStrokeColor(UIColor.black.cgColor)
ctx.setLineWidth(0)
ctx.setFillColor(UIColor.white.cgColor)
ctx.fill(bounds.inset(by: UIEdgeInsets(top: 0, left: 0, bottom: bounds.height*2/3, right: 0)))
ctx.setFillColor(self.blue)
ctx.fill(bounds.insetBy(dx: 0, dy: bounds.height/3))
ctx.setFillColor(self.red)
ctx.fill(bounds.inset(by: UIEdgeInsets(top: bounds.height*2/3, left: 0, bottom: 0, right: 0)))
ctx.setLineWidth(pixelWidth)
ctx.stroke(bounds.insetBy(dx: pixelWidth/2, dy: pixelWidth/2))
ctx.restoreGState()
}
}

View File

@ -0,0 +1,138 @@
import UIKit
import AutoCatCore
class PlateView: UIView {
// Some driver plate parameters from "ГОСТ Р 50577-93"
// http://docs.cntd.ru/document/gost-r-50577-93
private static let aspectRatio: CGFloat = 112.0/520.0
private static let fontHeightCoeff: CGFloat = 58.0/76.0
private var bgLayer = CALayer()
private var mainBgLayer = CALayer()
private var regionBgLayer = CALayer()
private var numberLayer = CenterTextLayer(coeff: fontHeightCoeff)
private var regionLayer = CenterTextLayer(coeff: fontHeightCoeff)
private var countryLayer = CenterTextLayer()
private var flagLayer = FlagLayer()
var number: PlateNumber? {
didSet {
self.layoutSubviews()
}
}
var foreground: UIColor? {
didSet {
self.layoutSubviews()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
func setup() {
self.layer.backgroundColor = UIColor.clear.cgColor
self.bgLayer.cornerRadius = 6 //self.bounds.height/8
self.layer.addSublayer(self.bgLayer)
self.mainBgLayer.cornerRadius = 4
self.layer.addSublayer(self.mainBgLayer)
self.regionBgLayer.cornerRadius = 4
self.layer.addSublayer(self.regionBgLayer)
self.numberLayer.alignmentMode = .center
self.numberLayer.contentsScale = UIScreen.main.scale
self.layer.addSublayer(self.numberLayer)
self.regionLayer.alignmentMode = .center
self.regionLayer.contentsScale = UIScreen.main.scale
self.layer.addSublayer(self.regionLayer)
self.countryLayer.contentsScale = UIScreen.main.scale
self.countryLayer.string = "RUS"
self.countryLayer.alignmentMode = .center
self.layer.addSublayer(self.countryLayer)
self.flagLayer.contentsScale = UIScreen.main.scale
self.layer.addSublayer(self.flagLayer)
}
private func pathForFlag(in rect: CGRect) -> CGPath {
let path = CGMutablePath()
let rect = CGPath(rect: rect, transform: nil)
path.addPath(rect)
return path
}
override func layoutSubviews() {
super.layoutSubviews()
guard let number = self.number else { return }
guard let fgColorMain = UIColor(named: "PlateForeground")?.cgColor else { return }
guard let bgColor = UIColor(named: "PlateBackground")?.cgColor else { return }
let fgColor = self.foreground?.cgColor ?? fgColorMain
self.bgLayer.backgroundColor = fgColor
self.bgLayer.frame = self.bounds
self.mainBgLayer.backgroundColor = bgColor
self.regionBgLayer.backgroundColor = bgColor
self.mainBgLayer.frame = self.bounds.inset(by: UIEdgeInsets(top: 2, left: 2, bottom: 2, right: self.bounds.width*0.27 + 1))
self.regionBgLayer.frame = self.bounds.inset(by: UIEdgeInsets(top: 2, left: self.bounds.width*0.73 + 1, bottom: 2, right: 2))
self.numberLayer.frame = self.mainBgLayer.frame.insetBy(dx: 4, dy: 0)
let font = UIFont(name: "RoadNumbers", size: self.mainBgLayer.frame.height*1.1) ?? UIFont.boldSystemFont(ofSize: 24)
let attributes: [NSAttributedString.Key: Any] = [
.kern: 3,
.font: font,
.foregroundColor: fgColor
]
let attributed = NSAttributedString(string: number.mainPart(), attributes: attributes)
self.numberLayer.string = attributed
let rbgSize = self.regionBgLayer.frame.size
self.regionLayer.frame = self.regionBgLayer.frame.inset(by: UIEdgeInsets(top: 2, left: 2, bottom: rbgSize.height*0.35, right: 2))
let regionFont = UIFont(name: "RoadNumbers", size: rbgSize.height*0.8) ?? UIFont.boldSystemFont(ofSize: 24)
let regionAttrs: [NSAttributedString.Key: Any] = [
.kern: 1,
.font: regionFont,
.foregroundColor: fgColor
]
let attributedRegion = NSAttributedString(string: number.region(), attributes: regionAttrs)
self.regionLayer.string = attributedRegion
self.countryLayer.foregroundColor = fgColor
self.countryLayer.frame = self.regionBgLayer.frame.inset(by: UIEdgeInsets(top: rbgSize.height*0.64, left: rbgSize.width*0.08, bottom: rbgSize.height*0.07, right: rbgSize.width*0.42))
self.countryLayer.fontSize = self.countryLayer.frame.size.height
let top = (self.regionBgLayer.frame.origin.y + rbgSize.height*0.68).rounded(.toNearestOrAwayFromZero)
let left = (self.regionBgLayer.frame.origin.x + rbgSize.width*0.62).rounded(.toNearestOrAwayFromZero)
let w = (rbgSize.width*0.34).rounded(.toNearestOrAwayFromZero)
let h = (rbgSize.height*0.28).rounded(.toNearestOrAwayFromZero)
self.flagLayer.frame = CGRect(x: left, y: top, width: w, height: h)
self.flagLayer.setNeedsDisplay()
}
override var intrinsicContentSize: CGSize {
guard self.bounds.width != 0 || self.bounds.height != 0 else {
return CGSize.zero
}
let curAspectRatio = self.bounds.height/self.bounds.width
if curAspectRatio >= PlateView.aspectRatio {
return CGSize(width: self.bounds.width, height: self.bounds.width*PlateView.aspectRatio)
} else {
return CGSize(width: self.bounds.height/PlateView.aspectRatio, height: self.bounds.height)
}
}
}

View File

@ -0,0 +1,99 @@
//
// Created by Selim Mustafaev on 06.03.2022.
//
import UIKit
import PKHUD
import AutoCatCore
class AuthController: UIViewController {
private lazy var emailField = ACTextField(placeholder: "Email")
.onTextChanged(textChanged)
.keyboardType(.emailAddress)
private lazy var passwordField = ACTextField(placeholder: "Password")
.onTextChanged(textChanged)
.secure(true)
private lazy var loginButton = ACButton(title: "Log in", onTapAsync: loginTapped)
.enable(false)
private lazy var signupButton = ACButton(title: "Sign up", onTapAsync: signupTapped)
.enable(false)
private lazy var stackView: UIStackView = {
let stack = UIStackView(arrangedSubviews: [
emailField,
passwordField,
loginButton,
signupButton
])
stack.axis = .vertical
stack.spacing = 16
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
view.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
stackView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.7),
loginButton.heightAnchor.constraint(equalToConstant: 40),
signupButton.heightAnchor.constraint(equalToConstant: 40)
])
}
func textChanged(_ text: String?) {
guard let email = emailField.text, let pass = passwordField.text else {
loginButton.isEnabled = false
signupButton.isEnabled = false
return
}
let enabled = email.count >= 5 && pass.count >= 5
loginButton.isEnabled = enabled
signupButton.isEnabled = enabled
}
// MARK: - Button handlers
func loginTapped() async {
guard let email = emailField.text, let password = passwordField.text else {
return
}
do {
HUD.show(.progress)
Settings.shared.user = try await Api.shared.login(email: email, password: password)
view.window?.rootViewController = MainTabController()
HUD.hide()
} catch {
HUD.hide()
show(error: error)
}
}
func signupTapped() async {
guard let email = emailField.text, let password = passwordField.text else {
return
}
do {
HUD.show(.progress)
Settings.shared.user = try await Api.shared.signup(email: email, password: password)
view.window?.rootViewController = MainTabController()
HUD.hide()
} catch {
HUD.hide()
show(error: error)
}
}
}

View File

@ -0,0 +1,112 @@
//
// CheckController.swift
// AutoCat2
//
// Created by Selim Mustafaev on 20.03.2022.
//
import UIKit
class CheckController: UIViewController {
public var onCheck: ((String) -> Void)?
private lazy var keyboardView: PNKeyboard = {
let keyboard = PNKeyboard(target: self.numberField, insets: .zero)
keyboard.delegate = self
return keyboard
}()
private lazy var numberField: SwiftMaskTextfield = {
let textField = SwiftMaskTextfield()
textField.formatPattern = "@###@@###"
textField.placeholder = "A001AA 777"
textField.borderStyle = .roundedRect
textField.font = .preferredFont(forTextStyle: .largeTitle)
textField.textAlignment = .center
textField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged)
return textField
}()
private let plateView = PlateView(frame: .zero)
private lazy var checkButton = ACButton(title: "Check", onTap: check)
.enable(false)
private lazy var stackView: UIStackView = {
let stack = UIStackView(arrangedSubviews: [plateView, checkButton])
stack.axis = .horizontal
stack.spacing = 16
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
private let titleLabel: UILabel = {
let label = UILabel()
label.text = "Check new plate number"
label.font = UIFont.preferredFont(forTextStyle: .headline)
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var mainStackView: UIStackView = {
let stack = UIStackView(arrangedSubviews: [titleLabel, stackView, keyboardView])
stack.axis = .vertical
stack.spacing = 16
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
override func viewDidLoad() {
super.viewDidLoad()
//view.backgroundColor = .systemBackground
checkButton.contentEdgeInsets = .init(top: 0, left: 8, bottom: 0, right: 8)
view.addSubview(mainStackView)
mainStackView.pin(to: view, insets: .init(top: 16, left: 16, bottom: 16, right: 16))
NSLayoutConstraint.activate([
plateView.heightAnchor.constraint(equalToConstant: 40)
])
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.numberField.becomeFirstResponder()
}
}
func check() {
guard let number = numberField.text else {
return
}
let numberNormalized = number.filter { !$0.isWhitespace }.uppercased()
numberField.resignFirstResponder()
onCheck?(numberNormalized)
}
}
extension CheckController: PNKeyboardDelegate {
func returnClicked() {
check()
}
}
extension CheckController: UITextFieldDelegate {
@objc func textFieldDidChange(_ textField: UITextField) {
guard let text = textField.text else {
self.checkButton.isEnabled = false
return
}
self.checkButton.isEnabled = text.count >= 8
}
}

View File

@ -0,0 +1,37 @@
//
// Created by Selim Mustafaev on 13.03.2022.
//
import UIKit
import PKHUD
import AutoCatCore
class HistoryController: UIViewController {
private lazy var tableView: UITableView = {
let table = UITableView()
table.translatesAutoresizingMaskIntoConstraints = false
return table
}()
override func viewDidLoad() {
super.viewDidLoad()
title = "Check history"
view.addSubview(tableView)
tableView.pin(to: view)
}
func checkPlateNumber(_ number: String) async {
print("Check plate number: ", number)
do {
HUD.show(.progress)
try await VehicleService.shared.check(plateNumber: number, force: false)
} catch {
show(error: error)
}
}
}

View File

@ -0,0 +1,62 @@
//
// Created by Selim Mustafaev on 06.03.2022.
//
import UIKit
import SwiftEntryKit
class MainTabController: UITabBarController, UITabBarControllerDelegate {
// Dummy controller, tabbar item will be used as a button
private let addController = UIViewController()
let historyController = HistoryController()
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .systemBackground
self.delegate = self
let historyNavController = UINavigationController(rootViewController: historyController)
historyNavController.tabBarItem = UITabBarItem(title: "Check", image: UIImage(systemName: "eye"), selectedImage: UIImage(systemName: "eye.fill"))
let settingsController = SettingsController()
settingsController.tabBarItem = UITabBarItem(title: "Settings", image: UIImage(systemName: "gear"), selectedImage: nil)
addController.tabBarItem = UITabBarItem(title: "New", image: UIImage(systemName: "plus"), selectedImage: nil)
self.viewControllers = [historyNavController, addController, settingsController]
}
func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
if viewController == addController {
showCheckPuller()
return false
} else {
return true
}
}
func showCheckPuller() {
var attributes = EKAttributes.bottomToast
attributes.displayDuration = .infinity
attributes.positionConstraints.keyboardRelation = .bind(offset: .none)
attributes.entryBackground = .color(color: .standardBackground)
attributes.screenBackground = .color(color: EKColor(UIColor(white: 0.3, alpha: 0.5)))
attributes.roundCorners = .top(radius: 24)
attributes.screenInteraction = .dismiss
attributes.entryInteraction = .forward
//attributes.shadow = .active(with: .init(color: .black, opacity: 0.2, radius: 24, offset: .zero))
let checkController = CheckController()
checkController.onCheck = { number in
SwiftEntryKit.dismiss()
Task {
await self.historyController.checkPlateNumber(number)
}
}
SwiftEntryKit.display(entry: checkController, using: attributes)
}
}

View File

@ -0,0 +1,12 @@
//
// Created by Selim Mustafaev on 13.03.2022.
//
import UIKit
class SettingsController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}
}

30
AutoCat2/Info.plist Normal file
View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIAppFonts</key>
<array>
<string>RoadNumbers.otf</string>
<string>RoadNumbers2.0.otf</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,63 @@
//
// SceneDelegate.swift
// AutoCat2
//
// Created by Selim Mustafaev on 05.03.2022.
//
import UIKit
import AutoCatCore
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
guard let scene = (scene as? UIWindowScene) else { return }
self.window = UIWindow(windowScene: scene)
if Settings.shared.user.token.isEmpty {
self.window?.rootViewController = AuthController()
} else {
self.window?.rootViewController = MainTabController()
}
self.window?.makeKeyAndVisible()
}
func sceneDidDisconnect(_ scene: UIScene) {
// Called as the scene is being released by the system.
// This occurs shortly after the scene enters the background, or when its session is discarded.
// Release any resources associated with this scene that can be re-created the next time the scene connects.
// The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
}
func sceneDidBecomeActive(_ scene: UIScene) {
// Called when the scene has moved from an inactive state to an active state.
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
}
func sceneWillResignActive(_ scene: UIScene) {
// Called when the scene will move from an active state to an inactive state.
// This may occur due to temporary interruptions (ex. an incoming phone call).
}
func sceneWillEnterForeground(_ scene: UIScene) {
// Called as the scene transitions from the background to the foreground.
// Use this method to undo the changes made on entering the background.
}
func sceneDidEnterBackground(_ scene: UIScene) {
// Called as the scene transitions from the foreground to the background.
// Use this method to save data, release shared resources, and store enough scene-specific state information
// to restore the scene back to its current state.
}
}

View File

@ -0,0 +1,51 @@
//
// CenterTextLayer.swift
// CenterTextLayer
//
// Created by Cem Olcay on 12/04/2017.
//
// http://stackoverflow.com/a/41518502/2048130
//
#if os(iOS) || os(tvOS)
import UIKit
#elseif os(OSX)
import AppKit
#endif
public class CenterTextLayer: CATextLayer {
var heightCoefficient: CGFloat = 1
public override init() {
super.init()
}
public override init(layer: Any) {
super.init(layer: layer)
}
public required init(coder aDecoder: NSCoder) {
super.init(layer: aDecoder)
}
public init(coeff: CGFloat) {
super.init()
self.heightCoefficient = coeff
}
public override func draw(in ctx: CGContext) {
#if os(iOS) || os(tvOS)
let multiplier = CGFloat(1)
#elseif os(OSX)
let multiplier = CGFloat(-1)
#endif
let h = ((string as? NSAttributedString)?.size().height ?? fontSize)*heightCoefficient;
let yDiff = (bounds.size.height - h) / 2 * multiplier
ctx.saveGState()
ctx.translateBy(x: 0.0, y: yDiff)
super.draw(in: ctx)
ctx.restoreGState()
}
}

View File

@ -0,0 +1,269 @@
/*
* MIT License
*
* Copyright (c) 2016 Gabriel Maccori Kozma
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import UIKit
//**********************************************************************************************************
//
// MARK: - Constants -
//
//**********************************************************************************************************
//**********************************************************************************************************
//
// MARK: - Definitions -
//
//**********************************************************************************************************
//**********************************************************************************************************
//
// MARK: - Class -
//
//**********************************************************************************************************
open class SwiftMaskTextfield : UITextField {
//**************************************************
// MARK: - Properties
//**************************************************
public let lettersAndDigitsReplacementChar: String = "*"
public let anyLetterReplecementChar: String = "@"
public let lowerCaseLetterReplecementChar: String = "a"
public let upperCaseLetterReplecementChar: String = "A"
public let digitsReplecementChar: String = "#"
/**
Var that holds the format pattern that you wish to apply
to some text
If the pattern is set to "" no mask would be applied and
the textfield would behave like a normal one
*/
@IBInspectable open var formatPattern: String = ""
/**
Var that holds the prefix to be added to the textfield
If the prefix is set to "" no string will be added to the beggining
of the text
*/
@IBInspectable open var prefix: String = ""
/**
Var that have the maximum length, based on the mask set
*/
open var maxLength: Int {
get {
return formatPattern.count + prefix.count
}
}
/**
Overriding the var text from UITextField so if any text
is applied programmatically by calling formatText
*/
override open var text: String? {
set {
super.text = newValue
self.formatText()
}
get {
return super.text
}
}
//**************************************************
// MARK: - Constructors
//**************************************************
public required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.setup()
}
public override init(frame: CGRect) {
super.init(frame: frame)
self.setup()
}
deinit {
self.deRegisterForNotifications()
}
//**************************************************
// MARK: - Private Methods
//**************************************************
fileprivate func setup() {
self.registerForNotifications()
}
fileprivate func registerForNotifications() {
NotificationCenter.default.addObserver(self,
selector: #selector(textDidChange),
name: NSNotification.Name(rawValue: "UITextFieldTextDidChangeNotification"),
object: self)
}
fileprivate func deRegisterForNotifications() {
NotificationCenter.default.removeObserver(self)
}
@objc fileprivate func textDidChange() {
self.undoManager?.removeAllActions()
self.formatText()
}
fileprivate func getOnlyDigitsString(_ string: String) -> String {
let charactersArray = string.components(separatedBy: CharacterSet.decimalDigits.inverted)
return charactersArray.joined(separator: "")
}
fileprivate func getOnlyLettersString(_ string: String) -> String {
let charactersArray = string.components(separatedBy: CharacterSet.letters.inverted)
return charactersArray.joined(separator: "")
}
fileprivate func getUppercaseLettersString(_ string: String) -> String {
let charactersArray = string.components(separatedBy: CharacterSet.uppercaseLetters.inverted)
return charactersArray.joined(separator: "")
}
fileprivate func getLowercaseLettersString(_ string: String) -> String {
let charactersArray = string.components(separatedBy: CharacterSet.lowercaseLetters.inverted)
return charactersArray.joined(separator: "")
}
fileprivate func getFilteredString(_ string: String) -> String {
let charactersArray = string.components(separatedBy: CharacterSet.alphanumerics.inverted)
return charactersArray.joined(separator: "")
}
fileprivate func getStringWithoutPrefix(_ string: String) -> String {
if string.range(of: self.prefix) != nil {
if string.count > self.prefix.count {
let prefixIndex = string.index(string.endIndex, offsetBy: (string.count - self.prefix.count) * -1)
return String(string[prefixIndex...])
} else if string.count == self.prefix.count {
return ""
}
}
return string
}
//**************************************************
// MARK: - Self Public Methods
//**************************************************
/**
Func that formats the text based on formatPattern
Override this function if you want to customize the behaviour of
the class
*/
open func formatText() {
var currentTextForFormatting = ""
if let text = super.text {
if text.count > 0 {
currentTextForFormatting = self.getStringWithoutPrefix(text)
}
}
if self.maxLength > 0 {
var formatterIndex = self.formatPattern.startIndex, currentTextForFormattingIndex = currentTextForFormatting.startIndex
var finalText = ""
currentTextForFormatting = self.getFilteredString(currentTextForFormatting)
if currentTextForFormatting.count > 0 {
while true {
let formatPatternRange = formatterIndex ..< formatPattern.index(after: formatterIndex)
let currentFormatCharacter = String(self.formatPattern[formatPatternRange])
let currentTextForFormattingPatterRange = currentTextForFormattingIndex ..< currentTextForFormatting.index(after: currentTextForFormattingIndex)
let currentTextForFormattingCharacter = String(currentTextForFormatting[currentTextForFormattingPatterRange])
switch currentFormatCharacter {
case self.lettersAndDigitsReplacementChar:
finalText += currentTextForFormattingCharacter
currentTextForFormattingIndex = currentTextForFormatting.index(after: currentTextForFormattingIndex)
formatterIndex = formatPattern.index(after: formatterIndex)
case self.anyLetterReplecementChar:
let filteredChar = self.getOnlyLettersString(currentTextForFormattingCharacter)
if !filteredChar.isEmpty {
finalText += filteredChar
formatterIndex = formatPattern.index(after: formatterIndex)
}
currentTextForFormattingIndex = currentTextForFormatting.index(after: currentTextForFormattingIndex)
case self.lowerCaseLetterReplecementChar:
let filteredChar = self.getLowercaseLettersString(currentTextForFormattingCharacter)
if !filteredChar.isEmpty {
finalText += filteredChar
formatterIndex = formatPattern.index(after: formatterIndex)
}
currentTextForFormattingIndex = currentTextForFormatting.index(after: currentTextForFormattingIndex)
case self.upperCaseLetterReplecementChar:
let filteredChar = self.getUppercaseLettersString(currentTextForFormattingCharacter)
if !filteredChar.isEmpty {
finalText += filteredChar
formatterIndex = formatPattern.index(after: formatterIndex)
}
currentTextForFormattingIndex = currentTextForFormatting.index(after: currentTextForFormattingIndex)
case self.digitsReplecementChar:
let filteredChar = self.getOnlyDigitsString(currentTextForFormattingCharacter)
if !filteredChar.isEmpty {
finalText += filteredChar
formatterIndex = formatPattern.index(after: formatterIndex)
}
currentTextForFormattingIndex = currentTextForFormatting.index(after: currentTextForFormattingIndex)
default:
finalText += currentFormatCharacter
formatterIndex = formatPattern.index(after: formatterIndex)
}
if formatterIndex >= self.formatPattern.endIndex ||
currentTextForFormattingIndex >= currentTextForFormatting.endIndex {
break
}
}
}
if finalText.count > 0 {
super.text = "\(self.prefix)\(finalText)"
} else {
super.text = finalText
}
if let text = self.text {
if text.count > self.maxLength {
super.text = String(text[text.index(text.startIndex, offsetBy: self.maxLength)])
}
}
}
}
}

View File

@ -0,0 +1,19 @@
//
// ViewController.swift
// AutoCat2
//
// Created by Selim Mustafaev on 05.03.2022.
//
import UIKit
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
}

View File

@ -1,7 +0,0 @@
import Foundation
protocol ApiMethodMockProtocol {
var path: String { get }
var httpMethod: String { get }
func response(headers: [String: String], params: [String: Any]) -> (status: Int, data: Data?)
}

View File

@ -1,96 +0,0 @@
import Foundation
extension URL {
public var queryParameters: [String: String]? {
guard
let components = URLComponents(url: self, resolvingAgainstBaseURL: true),
let queryItems = components.queryItems else { return nil }
return queryItems.reduce(into: [String: String]()) { (result, item) in
result[item.name] = item.value
}
}
}
extension URLRequest {
func bodySteamAsJSON() -> [String: Any]? {
guard let bodyStream = self.httpBodyStream else { return nil }
bodyStream.open()
// Will read 16 chars per iteration. Can use bigger buffer if needed
let bufferSize: Int = 16
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
var dat = Data()
while bodyStream.hasBytesAvailable {
let readDat = bodyStream.read(buffer, maxLength: bufferSize)
dat.append(buffer, count: readDat)
}
buffer.deallocate()
bodyStream.close()
return try? JSONSerialization.jsonObject(with: dat, options: JSONSerialization.ReadingOptions.allowFragments) as? [String: Any]
}
}
class MockURLProtocol: URLProtocol {
static var baseUrl: String = ""
static var apiMethodMocks: [ApiMethodMockProtocol] = []
override class func canInit(with request: URLRequest) -> Bool {
return true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
override func startLoading() {
guard let requestUrl = request.url else { return }
let methodMock = MockURLProtocol.apiMethodMocks.first {
return request.url?.absoluteString == MockURLProtocol.baseUrl + $0.path
&& request.httpMethod == $0.httpMethod
}
guard let methodMock = methodMock else {
if let response = HTTPURLResponse(url: requestUrl, statusCode: 404, httpVersion: "HTTP/2", headerFields: [:]) {
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocolDidFinishLoading(self)
}
return
}
// Assuming we use url parameters in GET requests and JSON-encoded body in everything else
var params: [String: Any] = [:]
if request.httpBodyStream != nil {
if let bodyDict = request.bodySteamAsJSON() {
params = bodyDict
}
} else {
if let urlParams = requestUrl.queryParameters {
params = urlParams
}
}
let result = methodMock.response(headers: request.allHTTPHeaderFields ?? [:], params: params)
guard let response = HTTPURLResponse(url: requestUrl, statusCode: result.status, httpVersion: "HTTP/2", headerFields: [:]) else { return }
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
if let data = result.data {
client?.urlProtocol(self, didLoad: data)
}
client?.urlProtocolDidFinishLoading(self)
//client?.urlProtocol(self, didFailWithError: error)
}
override func stopLoading() {
}
}

View File

@ -1,39 +0,0 @@
import Foundation
import AutoCat2
open class ApiMethodMock: ApiMethodMockProtocol {
private(set) var path: String
private(set) var httpMethod: String
init(httpMethod: String, path: String) {
self.httpMethod = httpMethod
self.path = path
}
func readData(from path: String) -> Data? {
guard let url = Bundle(for: type(of: self)).url(forResource: path, withExtension: "json") else { return nil }
return try? Data(contentsOf: url)
}
func error(message: String, code: ApiErrorCode? = nil) -> Data? {
var errorData: [String: AnyEncodable] = [
"success": false,
"error": AnyEncodable(message)
]
if let code = code {
errorData["errorCode"] = AnyEncodable(code)
}
return try? JSONEncoder().encode(errorData)
}
func notFoundResponse() -> (status: Int, data: Data?) {
return (status: 404, data: self.error(message: "Not found"))
}
open func response(headers: [String : String], params: [String : Any]) -> (status: Int, data: Data?) {
return self.notFoundResponse()
}
}

View File

@ -1,24 +0,0 @@
import Foundation
class LoginMethodMock: ApiMethodMock {
private var login: String
private var password: String
init(httpMethod: String, path: String, login: String, password: String) {
self.login = login
self.password = password
super.init(httpMethod: httpMethod, path: path)
}
override func response(headers: [String : String], params: [String : Any]) -> (status: Int, data: Data?) {
guard let login = params["email"] as? String, let password = params["password"] as? String else {
return (status: 400, data: self.error(message: "Invalid parameters"))
}
if login != self.login || password != self.password {
return (status: 200, data: self.error(message: "Incorrect login or password", code: .invalidLoginOrPassword))
}
return (status: 200, data: readData(from: "login_success"))
}
}

View File

@ -1,8 +0,0 @@
{
"success": true,
"data": {
"_id": "832c1bd4-5caa-4c9d-b24c-4c000cd8a793",
"email": "selim@fastmail.fm",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InNlbGltQGZhc3RtYWlsLmZtIiwiaWF0IjoxNjI2MjgwNDgxLCJleHAiOjE2NTc4MTY0ODF9.eU6wpacgCSnhM4EiyMY2lUptsfSfHz9guuvOsAw4X90"
}
}

View File

@ -1,45 +0,0 @@
import XCTest
import AutoCat2
class ApiTests: XCTestCase {
private var api: ApiProtocol!
private let testLogin = "test@gmail.com"
private let testPassword = "12345"
override func setUpWithError() throws {
MockURLProtocol.baseUrl = Constants.baseUrl
MockURLProtocol.apiMethodMocks = [
LoginMethodMock(httpMethod: "POST", path: "user/login", login: self.testLogin, password: self.testPassword)
]
let sessionConfig = URLSessionConfiguration.default
sessionConfig.protocolClasses = [MockURLProtocol.self]
let session = URLSession(configuration: sessionConfig)
self.api = Api(session: session)
}
override func tearDownWithError() throws {
MockURLProtocol.baseUrl = ""
MockURLProtocol.apiMethodMocks = []
}
func testLoginSuccess() async throws {
let user = try await self.api.login(email: self.testLogin, password: self.testPassword)
XCTAssertTrue(!user.token.isEmpty)
}
func testLoginInvalidParams() async throws {
do {
_ = try await self.api.login(email: "", password: "")
} catch let error as ApiError {
XCTAssertTrue(error.code == .invalidLoginOrPassword)
return
} catch {
XCTFail("Wrong exception type")
}
XCTFail("Exception expected")
}
}

View File

@ -0,0 +1,36 @@
//
// AutoCat2Tests.swift
// AutoCat2Tests
//
// Created by Selim Mustafaev on 05.03.2022.
//
import XCTest
@testable import AutoCat2
class AutoCat2Tests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
// Any test you write for XCTest can be annotated as throws and async.
// Mark your test throws to produce an unexpected failure when your test encounters an uncaught error.
// Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards.
}
func testPerformanceExample() throws {
// This is an example of a performance test case.
self.measure {
// Put the code you want to measure the time of here.
}
}
}

View File

@ -1,67 +0,0 @@
import XCTest
import AutoCat2
class SettingsTests: XCTestCase {
private var settings: SettingsProtocol!
override func setUpWithError() throws {
guard let userDefaults = UserDefaults(suiteName: #file) else {
throw CocoaError.error("Failed to create UserDefaults")
}
userDefaults.removePersistentDomain(forName: #file)
self.settings = Settings(defaults: userDefaults)
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func testAlternativeOrder() {
XCTAssert(self.settings.recognizeAlternativeOrder == false, "recognizeAlternativeOrder: wrong default value")
self.settings.recognizeAlternativeOrder = true
XCTAssert(self.settings.recognizeAlternativeOrder == true, "recognizeAlternativeOrder: failed to change value")
}
func testShortenedNumbers() {
XCTAssert(self.settings.recognizeShortenedNumbers == false, "recognizeShortenedNumbers: wrong default value")
self.settings.recognizeShortenedNumbers = true
XCTAssert(self.settings.recognizeShortenedNumbers == true, "recognizeShortenedNumbers: failed to change value")
}
func testDefaultRegion() {
XCTAssert(self.settings.defaultRegion == "161", "defaultRegion: wrong default value")
self.settings.defaultRegion = "761"
XCTAssert(self.settings.defaultRegion == "761", "defaultRegion: failed to change value")
}
func testRecordBeep() {
XCTAssert(self.settings.recordBeep == false, "recordBeep: wrong default value")
self.settings.recordBeep = true
XCTAssert(self.settings.recordBeep == true, "recordBeep: failed to change value")
}
func testDebugInfo() {
XCTAssert(self.settings.showDebugInfo == false, "showDebugInfo: wrong default value")
self.settings.showDebugInfo = true
XCTAssert(self.settings.showDebugInfo == true, "showDebugInfo: failed to change value")
}
func testDefaultUser() {
XCTAssert(self.settings.user.email.isEmpty, "Default user email is not empty")
XCTAssert(self.settings.user.token.isEmpty, "Default user token is not empty")
XCTAssert(self.settings.user.firebaseIdToken == nil, "Default user firebase ID token is not nil")
XCTAssert(self.settings.user.firebaseRefreshToken == nil, "Default user firebase refresh token is not nil")
}
func testSaveUser() {
self.settings.user.token = "TestToken"
XCTAssert(self.settings.user.token == "TestToken", "Failed to save user token to settings")
self.settings.user.firebaseIdToken = "TestFirebaseToken"
XCTAssert(self.settings.user.firebaseIdToken == "TestFirebaseToken", "Failed to save user firebaseIdToken to settings")
self.settings.user.firebaseRefreshToken = "TestResreshToken"
XCTAssert(self.settings.user.firebaseRefreshToken == "TestResreshToken", "Failed to save user firebaseRefreshToken to settings")
}
}

View File

@ -1,13 +1,13 @@
// //
// Tests_macOS.swift // AutoCat2UITests.swift
// Tests macOS // AutoCat2UITests
// //
// Created by Selim Mustafaev on 04.07.2021. // Created by Selim Mustafaev on 05.03.2022.
// //
import XCTest import XCTest
class Tests_macOS: XCTestCase { class AutoCat2UITests: XCTestCase {
override func setUpWithError() throws { override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class. // Put setup code here. This method is called before the invocation of each test method in the class.

View File

@ -0,0 +1,32 @@
//
// AutoCat2UITestsLaunchTests.swift
// AutoCat2UITests
//
// Created by Selim Mustafaev on 05.03.2022.
//
import XCTest
class AutoCat2UITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}
override func setUpWithError() throws {
continueAfterFailure = false
}
func testLaunch() throws {
let app = XCUIApplication()
app.launch()
// Insert steps here to perform after app launch but before taking a screenshot,
// such as logging into a test account or navigating somewhere in the app
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
add(attachment)
}
}

View File

@ -1,16 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="19461" systemVersion="21A559" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="20086" systemVersion="21A559" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
<entity name="VBrand" representedClassName="CDVBrand" syncable="YES" codeGenerationType="class"> <entity name="VBrand" representedClassName=".CDVBrand" syncable="YES" codeGenerationType="class">
<attribute name="logo" optional="YES" attributeType="String"/> <attribute name="logo" optional="YES" attributeType="String"/>
<relationship name="name" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="VName" inverseName="brand" inverseEntity="VName"/> <relationship name="name" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="VName" inverseName="brand" inverseEntity="VName"/>
<relationship name="vehicle" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Vehicle" inverseName="brand" inverseEntity="Vehicle"/> <relationship name="vehicle" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Vehicle" inverseName="brand" inverseEntity="Vehicle"/>
</entity> </entity>
<entity name="Vehicle" representedClassName="CDVehicle" syncable="YES" codeGenerationType="class"> <entity name="Vehicle" representedClassName=".CDVehicle" syncable="YES" codeGenerationType="class">
<attribute name="currentNumber" optional="YES" attributeType="String"/> <attribute name="currentNumber" optional="YES" attributeType="String"/>
<attribute name="number" optional="YES" attributeType="String"/> <attribute name="number" optional="YES" attributeType="String"/>
<relationship name="brand" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="VBrand" inverseName="vehicle" inverseEntity="VBrand"/> <relationship name="brand" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="VBrand" inverseName="vehicle" inverseEntity="VBrand"/>
</entity> </entity>
<entity name="VName" representedClassName="CDVName" syncable="YES" codeGenerationType="class"> <entity name="VName" representedClassName=".CDVName" syncable="YES" codeGenerationType="class">
<attribute name="normalized" optional="YES" attributeType="String"/> <attribute name="normalized" optional="YES" attributeType="String"/>
<attribute name="original" optional="YES" attributeType="String"/> <attribute name="original" optional="YES" attributeType="String"/>
<relationship name="brand" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="VBrand" inverseName="name" inverseEntity="VBrand"/> <relationship name="brand" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="VBrand" inverseName="name" inverseEntity="VBrand"/>

View File

@ -0,0 +1,13 @@
# ``AutoCatCore``
<!--@START_MENU_TOKEN@-->Summary<!--@END_MENU_TOKEN@-->
## Overview
<!--@START_MENU_TOKEN@-->Text<!--@END_MENU_TOKEN@-->
## Topics
### <!--@START_MENU_TOKEN@-->Group<!--@END_MENU_TOKEN@-->
- <!--@START_MENU_TOKEN@-->``Symbol``<!--@END_MENU_TOKEN@-->

18
AutoCatCore/AutoCatCore.h Normal file
View File

@ -0,0 +1,18 @@
//
// AutoCatCore.h
// AutoCatCore
//
// Created by Selim Mustafaev on 05.03.2022.
//
#import <Foundation/Foundation.h>
//! Project version number for AutoCatCore.
FOUNDATION_EXPORT double AutoCatCoreVersionNumber;
//! Project version string for AutoCatCore.
FOUNDATION_EXPORT const unsigned char AutoCatCoreVersionString[];
// In this header, you should import all the public headers of your framework using statements like #import <AutoCatCore/PublicHeader.h>

View File

@ -4,7 +4,7 @@ class Response<T>: Decodable where T: Decodable {
let success: Bool let success: Bool
let data: T? let data: T?
let error: String? let error: String?
let errorCode: ApiErrorCode? let errorCode: Int?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case success case success
@ -22,7 +22,7 @@ class Response<T>: Decodable where T: Decodable {
errorCode = nil errorCode = nil
} else { } else {
error = try container.decode(String.self, forKey: .error) error = try container.decode(String.self, forKey: .error)
errorCode = try container.decodeIfPresent(ApiErrorCode.self, forKey: .errorCode) errorCode = try container.decodeIfPresent(Int.self, forKey: .errorCode)
data = nil data = nil
} }
} }

View File

@ -11,21 +11,37 @@ class StorageService: StorageServiceProtocol {
private let container: NSPersistentCloudKitContainer private let container: NSPersistentCloudKitContainer
static let shared = StorageService() static var shared: StorageService {
get async throws {
print("!!!!!!!!!!!!!!!!!!!!!!!!! StorageService init")
let service = StorageService()
try await service.loadPersistentStores()
return service
}
}
init(inMemory: Bool = false) { init(inMemory: Bool = false) {
container = NSPersistentCloudKitContainer(name: "AutoCat2") let bundle = Bundle(for: Self.self)
let url = bundle.url(forResource: "AutoCat2", withExtension: "momd")
let mom = NSManagedObjectModel(contentsOf: url!)
container = NSPersistentCloudKitContainer(name: "AutoCat2", managedObjectModel: mom!)
if inMemory { if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
} }
}
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? { private func loadPersistentStores() async throws {
// TODO: Handle error properly try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
fatalError("Unresolved error \(error), \(error.userInfo)") container.loadPersistentStores(completionHandler: { (storeDescription, error) in
} if let error = error as NSError? {
}) continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
})
}
} }
private func save() throws { private func save() throws {

View File

@ -5,14 +5,19 @@ protocol VehicleServiceProtocol {
func check(plateNumber: String, force: Bool) async throws -> CDVehicle func check(plateNumber: String, force: Bool) async throws -> CDVehicle
} }
class VehicleService: VehicleServiceProtocol { public class VehicleService: VehicleServiceProtocol {
private let api: ApiProtocol private let api: ApiProtocol
private let storage: StorageServiceProtocol private let storage: StorageServiceProtocol
static let shared = VehicleService() public static var shared: VehicleService {
get async throws {
let storageService = try await StorageService.shared
return VehicleService(api: Api.shared, storage: storageService)
}
}
init(api: ApiProtocol = Api.shared, storage: StorageServiceProtocol = StorageService.shared) { init(api: ApiProtocol = Api.shared, storage: StorageServiceProtocol) {
self.api = api self.api = api
self.storage = storage self.storage = storage
@ -20,7 +25,8 @@ class VehicleService: VehicleServiceProtocol {
// MARK: - VehicleServiceProtocol // MARK: - VehicleServiceProtocol
func check(plateNumber: String, force: Bool) async throws -> CDVehicle { @discardableResult
public func check(plateNumber: String, force: Bool) async throws -> CDVehicle {
let vehicle = try await api.check(plateNumber: plateNumber, force: force) let vehicle = try await api.check(plateNumber: plateNumber, force: force)
return try storage.store(vehicle: vehicle) return try storage.store(vehicle: vehicle)

View File

@ -30,10 +30,6 @@ public class Api: ApiProtocol {
// MARK: - Private wrappres // MARK: - Private wrappres
private func genError(_ msg: String, suggestion: String, code: Int = 0) -> Error {
return NSError(domain: "", code: code, userInfo: [NSLocalizedDescriptionKey: msg, NSLocalizedRecoverySuggestionErrorKey: suggestion])
}
private func createRequest<B,P>(api: String, method: String, body: B? = nil, params: [String:P]? = nil) -> URLRequest? where B: Encodable, P: LosslessStringConvertible { private func createRequest<B,P>(api: String, method: String, body: B? = nil, params: [String:P]? = nil) -> URLRequest? where B: Encodable, P: LosslessStringConvertible {
guard var urlComponents = URLComponents(string: Constants.baseUrl + api) else { return nil } guard var urlComponents = URLComponents(string: Constants.baseUrl + api) else { return nil }
@ -60,38 +56,54 @@ public class Api: ApiProtocol {
private func makeRequest<T,B,P>(api: String, method: String = "GET", body: B?, params: [String:P]? = nil) async throws -> T where T: Decodable, B: Encodable, P: LosslessStringConvertible { private func makeRequest<T,B,P>(api: String, method: String = "GET", body: B?, params: [String:P]? = nil) async throws -> T where T: Decodable, B: Encodable, P: LosslessStringConvertible {
guard let request = self.createRequest(api: api, method: method, body: body, params: params) else { guard let request = self.createRequest(api: api, method: method, body: body, params: params) else {
throw self.genError("Error creating request", suggestion: "") throw ApiError.message("Error creating request")
} }
let (data, response) = try await self.session.data(for: request) return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<T, Error>) in
guard let httpResponse = response as? HTTPURLResponse else {
throw self.genError("non-HTTP response received", suggestion: "") let task = self.session.dataTask(with: request) { data, response, error in
}
if let error = error {
// let str = String(data: data, encoding: .utf8) continuation.resume(throwing: error)
// print("================================") return
// if let string = str?.replacingOccurrences(of: "\\\"", with: "\"") }
// .replacingOccurrences(of: "\\'", with: "'")
// .replacingOccurrences(of: "\\n", with: "") { guard let data = data, data.count > 0 else {
// print(string) continuation.resume(throwing: ApiError.message("No data in response"))
// } return
// print("================================") }
do {
if data.count > 0 { // let str = String(data: data, encoding: .utf8)
let resp = try JSONDecoder().decode(Response<T>.self, from: data) // print("================================")
if resp.success { // if let string = str?.replacingOccurrences(of: "\\\"", with: "\"")
return resp.data! // .replacingOccurrences(of: "\\'", with: "'")
} else { // .replacingOccurrences(of: "\\n", with: "") {
throw ApiError(httpStatus: httpResponse.statusCode, message: resp.error, code: resp.errorCode) // print(string)
// }
// print("================================")
do {
let resp = try JSONDecoder().decode(Response<T>.self, from: data)
if resp.success {
continuation.resume(returning: resp.data!)
} else {
if let code = resp.errorCode {
continuation.resume(throwing: ApiError(code: code))
} else if let errorMessage = resp.error {
continuation.resume(throwing: ApiError.message(errorMessage))
} else {
continuation.resume(throwing: ApiError.generic)
}
}
} catch let error as Swift.DecodingError {
continuation.resume(throwing: ApiError.message((error as CustomDebugStringConvertible).debugDescription))
} catch {
continuation.resume(throwing: error)
} }
} else {
throw ApiError(httpStatus: httpResponse.statusCode)
} }
} catch let error as Swift.DecodingError {
throw CocoaError.error((error as CustomDebugStringConvertible).debugDescription) task.resume()
} catch {
throw error
} }
} }
@ -113,6 +125,7 @@ public class Api: ApiProtocol {
// MARK: - AutoCat public API // MARK: - AutoCat public API
@MainActor
public func login(email: String, password: String) async throws -> User { public func login(email: String, password: String) async throws -> User {
let body = [ let body = [
@ -122,7 +135,19 @@ public class Api: ApiProtocol {
return try await self.makeBodyRequest(api: "user/login", body: body) return try await self.makeBodyRequest(api: "user/login", body: body)
} }
@MainActor
public func signup(email: String, password: String) async throws -> User {
let body = [
"email": email,
"password": password
]
return try await self.makeBodyRequest(api: "user/signup", body: body)
}
@MainActor
public func check(plateNumber: String, force: Bool = false) async throws -> Vehicle { public func check(plateNumber: String, force: Bool = false) async throws -> Vehicle {
var body = [ var body = [

View File

@ -0,0 +1,31 @@
import Foundation
public enum ApiError: LocalizedError {
case generic
case message(String)
case httpError(Int)
case invalidLoginOrPassword
public var errorDescription: String? {
switch self {
case .generic:
return "Something bad happened"
case .message(let message):
return message
case .httpError(let status):
return "General http error (status \(status))"
case .invalidLoginOrPassword:
return "Invalid login or password"
}
}
init(code: Int) {
switch code {
case 0:
self = .invalidLoginOrPassword
default:
self = .generic
}
}
}

View File

@ -0,0 +1,36 @@
//
// AutoCatCoreTests.swift
// AutoCatCoreTests
//
// Created by Selim Mustafaev on 05.03.2022.
//
import XCTest
@testable import AutoCatCore
class AutoCatCoreTests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
// Any test you write for XCTest can be annotated as throws and async.
// Mark your test throws to produce an unexpected failure when your test encounters an uncaught error.
// Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards.
}
func testPerformanceExample() throws {
// This is an example of a performance test case.
self.measure {
// Put the code you want to measure the time of here.
}
}
}

View File

@ -1,38 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.000",
"green" : "0.000",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.182",
"green" : "0.225",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,20 +0,0 @@
//
// AutoCat2App.swift
// Shared
//
// Created by Selim Mustafaev on 04.07.2021.
//
import SwiftUI
@main
struct AutoCat2App: App {
let storageService = StorageService.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, storageService.context)
}
}
}

View File

@ -1,28 +0,0 @@
import Foundation
import SwiftUI
enum AlertMessage: Identifiable {
case info(title: String, body: String)
case error(error: Error)
var id: Int {
switch self {
case .info(let title, let body):
return title.hashValue + body.hashValue
case .error(let error):
return error.localizedDescription.hashValue
}
}
}
extension Alert {
init(_ message: AlertMessage) {
switch message {
case .info(let title, let body):
self.init(title: Text(title), message: Text(body))
case .error(let error):
let msg = (error as NSError).displayMessage
self.init(title: Text(msg.title), message: Text(msg.body))
}
}
}

View File

@ -1,68 +0,0 @@
import CoreLocation
extension NSError {
public var displayMessage: (title: String, body: String) {
// if let description = self.userInfo[NSLocalizedDescriptionKey] as? String {
// return (title: "Error", body: description)
// } else if let failure = self.userInfo[NSLocalizedFailureErrorKey] as? String, let reason = self.localizedFailureReason {
// if let recovery = self.localizedRecoverySuggestion {
// return (title: failure, body: reason + "\n" + recovery)
// } else {
// return (title: failure, body: reason)
// }
// } else {
// return (title: "Error", body: "")
// }
if let recovery = self.localizedRecoverySuggestion {
return (title: "Error", body: self.localizedDescription + "\n" + recovery)
} else {
return (title: "Error", body: self.localizedDescription)
}
}
}
extension CocoaError {
public static func error(_ description: String) -> NSError {
return error(Code(rawValue: 0), userInfo: [NSLocalizedDescriptionKey: description], url: nil) as NSError
}
public static func error(_ error: String, reason: String, suggestion: String? = nil) -> NSError {
var info = [
NSLocalizedFailureErrorKey: error,
NSLocalizedFailureReasonErrorKey: reason
]
if let suggestion = suggestion {
info[NSLocalizedRecoverySuggestionErrorKey] = suggestion
}
return self.error(Code(rawValue: 0), userInfo: info, url: nil) as NSError
}
}
extension CLError.Code: CustomStringConvertible {
public var description: String {
switch self {
case .locationUnknown: return "Location unknown"
case .denied: return "Access denied"
case .network: return "general, network-related error"
case .headingFailure: return "heading could not be determined"
case .regionMonitoringDenied: return "Location region monitoring has been denied"
case .regionMonitoringSetupDelayed: return "CL could not immediately initialize region monitoring"
case .regionMonitoringResponseDelayed: return "While events for this fence will be delivered, delivery will not occur immediately"
case .geocodeFoundNoResult: return "A geocode request yielded no result"
case .geocodeFoundPartialResult: return "A geocode request yielded a partial result"
case .geocodeCanceled: return "A geocode request was cancelled"
case .deferredFailed: return "Deferred mode failed"
case .deferredNotUpdatingLocation: return "Deferred mode failed because location updates disabled or paused"
case .deferredAccuracyTooLow: return "Deferred mode not supported for the requested accuracy"
case .deferredDistanceFiltered: return "Deferred mode does not support distance filters"
case .deferredCanceled: return "Deferred mode request canceled a previous request"
case .rangingUnavailable: return "Ranging cannot be performed"
case .rangingFailure: return "General ranging failure"
case .promptDeclined: return "Authorization request not presented to user"
default: return "Unknown location error (\(self.rawValue)"
}
}
}

View File

@ -1,55 +0,0 @@
//
// Persistence.swift
// Shared
//
// Created by Selim Mustafaev on 04.07.2021.
//
import CoreData
struct PersistenceController {
static let shared = PersistenceController()
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
// for _ in 0..<10 {
// let newItem = Item(context: viewContext)
// newItem.timestamp = Date()
// }
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return result
}()
let container: NSPersistentCloudKitContainer
init(inMemory: Bool = false) {
container = NSPersistentCloudKitContainer(name: "AutoCat2")
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
/*
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disallows writing.
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
* The device is out of space.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
}
}

View File

@ -1,35 +0,0 @@
import Foundation
public enum ApiErrorCode: Int, CustomStringConvertible, Codable {
case invalidLoginOrPassword = 0
public var description: String {
switch self {
case .invalidLoginOrPassword: return "Invalid login or password"
}
}
}
public class ApiError: LocalizedError {
public let httpStatus: Int
public let message: String?
public let code: ApiErrorCode?
init(httpStatus: Int, message: String? = nil, code: ApiErrorCode? = nil) {
self.httpStatus = httpStatus
self.message = message
self.code = code
}
public var errorDescription: String? {
if let code = code {
return code.description
}
if let message = message {
return message
}
return "General http error (status \(self.httpStatus))"
}
}

View File

@ -1,17 +0,0 @@
import Foundation
public class AuthVM: ObservableObject {
private let api: ApiProtocol
private var settings: SettingsProtocol
init(api: ApiProtocol = Api.shared, settings: SettingsProtocol = Settings.shared) {
self.api = api
self.settings = settings
}
public func login(user: String, password: String) async throws {
settings.user = try await api.login(email: user, password: password)
}
}

View File

@ -1,68 +0,0 @@
import SwiftUI
struct ACProgressView: View {
@State var isDeterminate: Bool = false
@State var progress: CGFloat = 0
@State var text: String? = nil
@State var isAnimating: Bool = false
var body: some View {
ZStack(alignment: .center) {
Color.black.opacity(0.4)
VStack {
VStack(spacing: 24) {
if self.isDeterminate {
ZStack {
Circle()
.stroke(Color.secondary.opacity(0.2), style: StrokeStyle(lineWidth: 4))
.frame(width: 100, height: 100)
Circle()
.trim(from: 0, to: self.progress)
.stroke(Color.blue, style: StrokeStyle(lineWidth: 4))
.rotationEffect(.degrees(-90))
.frame(width: 100, height: 100)
Text("\(Int(self.progress*100)) %")
}
} else {
let gradient = AngularGradient(
gradient: Gradient(colors: [Color.blue, Color.blue.opacity(0.01)]),
center: .center,
startAngle: .degrees(360),
endAngle: .degrees(0))
Circle()
.stroke(gradient, style: StrokeStyle(lineWidth: 4))
.rotationEffect(Angle(degrees: self.isAnimating ? 360 : 0))
.onAppear {
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
self.isAnimating = true
}
}
.onDisappear(perform: {
self.isAnimating = false
})
.frame(width: 100, height: 100)
}
if let msg = self.text {
Text(msg)
}
}
.padding(28)
}
//.frame(width: 160, height: 160)
.background(.regularMaterial)
.cornerRadius(20)
}
.edgesIgnoringSafeArea(.all)
}
}
struct ACProgressView_Previews: PreviewProvider {
static var previews: some View {
Group {
ACProgressView()
ACProgressView(isDeterminate: true, progress: 0.3, text: "Loading...")
.preferredColorScheme(.dark)
}
}
}

View File

@ -1,71 +0,0 @@
import SwiftUI
struct AuthView: View {
enum Field {
case email
case password
}
@ObservedObject var viewModel = AuthVM()
@State private var login: String = ""
@State private var password: String = ""
@State private var showProgress: Bool = false
@State private var alert: AlertMessage? = nil
@FocusState private var focus: Field?
var body: some View {
ZStack {
VStack(alignment: .center, spacing: 16) {
Spacer()
#if os(iOS)
TextField("Login", text: $login)
.focused($focus, equals: .email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
#else
TextField("Login", text: $login)
.focused($focus, equals: .email)
#endif
SecureField("Password", text: $password)
.focused($focus, equals: .password)
Button("Login") {
Task.init {
do {
self.focus = nil
self.showProgress = true
try await self.viewModel.login(user: self.login, password: self.password)
self.showProgress = false
} catch {
self.showProgress = false
self.alert = .error(error: error)
}
}
}
.alert(item: $alert, content: Alert.init)
Spacer()
}
.buttonStyle(.bordered)
.controlSize(.large)
.textFieldStyle(.roundedBorder)
.padding(20)
if self.showProgress {
ACProgressView()
}
}
}
}
struct AuthView_Previews: PreviewProvider {
static var previews: some View {
Group {
AuthView()
.previewDevice("iPhone 8")
AuthView()
.preferredColorScheme(.dark)
.previewDevice("iPhone 8")
}
}
}

View File

@ -1,35 +0,0 @@
import SwiftUI
struct CheckView: View {
@State private var number: String = ""
@FetchRequest(entity: CDVehicle.entity(), sortDescriptors: []) var vehicles: FetchedResults<CDVehicle>
var body: some View {
NavigationView {
VStack(alignment: .center, spacing: 16) {
TextField("A123AA777", text: $number)
Button("Check") {
}
List(vehicles) { vehicle in
Text(vehicle.number ?? "<none>")
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("New") {}
}
}
#if os(iOS)
.navigationBarTitle("Title", displayMode: .inline)
#endif
}
}
}
struct CheckView_Previews: PreviewProvider {
static var previews: some View {
CheckView()
.preferredColorScheme(.dark)
}
}

View File

@ -1,20 +0,0 @@
import SwiftUI
import CoreData
struct ContentView: View {
@StateObject var settings = Settings.shared
var body: some View {
if settings.user.token.isEmpty {
AuthView()
} else {
MainView()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

View File

@ -1,17 +0,0 @@
import SwiftUI
struct MainView: View {
var body: some View {
#if os(iOS)
MainViewSmall()
#else
MainViewBig()
#endif
}
}
struct MainView_Previews: PreviewProvider {
static var previews: some View {
MainView()
}
}

View File

@ -1,45 +0,0 @@
import SwiftUI
struct MainViewSmall: View {
var body: some View {
NavigationView {
TabView {
CheckView()
.tabItem {
Image(systemName: "eye")
Text("Check")
}
RecordsView()
.tabItem {
Image(systemName: "recordingtape")
Text("Records")
}
SearchView()
.tabItem {
Image(systemName: "magnifyingglass")
Text("Search")
}
SettingsView()
.tabItem {
Image(systemName: "gear")
Text("Settings")
}
}
#if os(iOS)
.navigationBarHidden(true)
#endif
Text("detail")
}
}
}
struct MainViewSmall_Previews: PreviewProvider {
static var previews: some View {
Group {
MainViewSmall()
MainViewSmall()
.previewInterfaceOrientation(.landscapeLeft)
.previewDevice("iPad Pro (9.7-inch)")
}
}
}

View File

@ -1,67 +0,0 @@
//
// PlateNumberView.swift
// AutoCat2
//
// Created by Selim Mustafaev on 21.11.2021.
//
import SwiftUI
struct PlateNumberView: View {
let number: PlateNumber
let unrecognized: Bool
let outdated: Bool
private var fgColor: Color {
if unrecognized {
return Color("PlateBackgroundError")
} else {
return Color("PlateForeground")
}
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 6)
.fill(fgColor)
GeometryReader { geometry in
HStack(alignment: .center, spacing: 2) {
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(Color("PlateBackground"))
Text(number.mainPart())
.font(Font.custom("RoadNumbers", size: geometry.size.height*0.9))
}
.frame(width: geometry.size.width*0.73 - 1)
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(Color("PlateBackground"))
VStack(spacing: 0) {
Text(number.region())
.frame(height: geometry.size.height*0.65, alignment: .center)
HStack {
}
.frame(height: geometry.size.height*0.35, alignment: .center)
}
}
.frame(width: geometry.size.width*0.27 - 1)
}
}
.padding(2)
}
.aspectRatio(520.0/112.0, contentMode: .fit)
}
}
struct PlateNumberView_Previews: PreviewProvider {
static var previews: some View {
Group {
PlateNumberView(number: PlateNumber("Е201АМ761"), unrecognized: false, outdated: false)
PlateNumberView(number: PlateNumber("Е201АМ761"), unrecognized: true, outdated: false)
PlateNumberView(number: PlateNumber("Е201АМ761"), unrecognized: false, outdated: true)
}
.previewLayout(.fixed(width: 200, height: 50))
}
}

View File

@ -1,13 +0,0 @@
import SwiftUI
struct RecordsView: View {
var body: some View {
Text("Records view")
}
}
struct RecordsView_Previews: PreviewProvider {
static var previews: some View {
RecordsView()
}
}

View File

@ -1,13 +0,0 @@
import SwiftUI
struct ReportView: View {
var body: some View {
Text("Report view")
}
}
struct ReportView_Previews: PreviewProvider {
static var previews: some View {
ReportView()
}
}

View File

@ -1,13 +0,0 @@
import SwiftUI
struct SearchView: View {
var body: some View {
Text("Search view")
}
}
struct SearchView_Previews: PreviewProvider {
static var previews: some View {
SearchView()
}
}

View File

@ -1,13 +0,0 @@
import SwiftUI
struct SettingsView: View {
var body: some View {
Text("Search view")
}
}
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
SettingsView()
}
}

View File

@ -1,56 +0,0 @@
//
// CheckNumberPopup.swift
// AutoCat2 (macOS)
//
// Created by Selim Mustafaev on 07.11.2021.
//
import SwiftUI
struct CheckNumberPopup: View {
@Environment(\.presentationMode) var presentation
@State var plateNumber: String = ""
@State var progress = false
@State private var alert: AlertMessage? = nil
var body: some View {
VStack(alignment: .center, spacing: 8) {
Text("Check new plate number")
TextField("qwe", text: $plateNumber, prompt: Text("asdf"))
.disabled(progress)
Spacer()
if progress {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
Spacer()
HStack {
Button("Cancel") {
self.presentation.wrappedValue.dismiss()
}
.disabled(progress)
Button("Check") {
Task.init {
progress = true
do {
try await VehicleService.shared.check(plateNumber: plateNumber.uppercased(), force: false)
} catch {
alert = .error(error: error)
}
progress = false
}
}
.alert(item: $alert, content: Alert.init)
.disabled(progress)
}
}
.padding()
.frame(minWidth: 300, minHeight: 180)
}
}
struct CheckNumberPopup_Previews: PreviewProvider {
static var previews: some View {
CheckNumberPopup()
}
}

View File

@ -1,84 +0,0 @@
import SwiftUI
struct MainViewBig: View {
var body: some View {
NavigationView {
SidebarView()
Text("Master")
Text("Detail")
}
}
}
struct SidebarView: View {
@State var selection: String?
@State private var showAddPlateNumberView = false
@FetchRequest(entity: CDVehicle.entity(), sortDescriptors: []) var vehicles: FetchedResults<CDVehicle>
var body: some View {
List(selection: $selection) {
Section("History") {
NavigationLink(destination: VehiclesListView(vehicles: vehicles)) {
Label("All", systemImage: "car.2")
.badge(vehicles.count)
}
NavigationLink(destination: VehiclesListView(vehicles: vehicles.filter(\.unrecognized))) {
Label("Unreconized", systemImage: "eye.slash")
.badge(vehicles.filter(\.unrecognized).count)
}
NavigationLink(destination: VehiclesListView(vehicles: vehicles.filter(\.outdated))) {
Label("Outdated", systemImage: "wind")
.badge(vehicles.filter(\.outdated).count)
}
}
.collapsible(false)
Section("Other") {
Label("Recordings", systemImage: "waveform.path")
Label("Search", systemImage: "magnifyingglass")
}
.collapsible(false)
}
.frame(minWidth: 180)
.sheet(isPresented: $showAddPlateNumberView, onDismiss: {
print("Dismiss")
}, content: {
CheckNumberPopup()
})
.toolbar {
ToolbarItem {
Spacer()
}
ToolbarItem {
Button {
self.showAddPlateNumberView = true
} label: {
Image(systemName: "plus")
}
}
}
}
}
struct VehiclesListView<VehicleCollection>: View where VehicleCollection: RandomAccessCollection, VehicleCollection.Element == CDVehicle {
var vehicles: VehicleCollection
@State var selection: CDVehicle?
var body: some View {
List(selection: $selection) {
ForEach(vehicles, id: \.self) { vehicle in
let number = PlateNumber(vehicle.number ?? "")
PlateNumberView(number: number, unrecognized: vehicle.unrecognized, outdated: vehicle.outdated)
}
}
}
}
struct MainViewBig_Previews: PreviewProvider {
static var previews: some View {
MainViewBig()
}
}

View File

@ -1,21 +0,0 @@
import XCTest
class Tests_iOS: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
// Use recording to get started writing UI tests.
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
}