From bdee6cc25e1c659d0500b37f5a394d6f45fcf795 Mon Sep 17 00:00:00 2001
From: Bruno Muniz Azevedo Filho <bruno@elixxir.io>
Date: Tue, 19 Jul 2022 17:48:21 -0300
Subject: [PATCH] Finished qr part of search 2.0

---
 Sources/App/DependencyRegistrator.swift       |   2 +
 Sources/Integration/Session/Session.swift     |   6 +
 .../Controllers/CameraController.swift        |  61 ++++++
 .../Controllers/SearchRightController.swift   |  84 +++++---
 .../Coordinator/SearchCoordinator.swift       |  19 ++
 .../ViewModels/SearchRightViewModel.swift     | 191 +++++++++---------
 .../SearchFeature/Views/SearchRightView.swift |  62 +++++-
 7 files changed, 301 insertions(+), 124 deletions(-)
 create mode 100644 Sources/SearchFeature/Controllers/CameraController.swift

diff --git a/Sources/App/DependencyRegistrator.swift b/Sources/App/DependencyRegistrator.swift
index 87b8e7dd..2d5abdec 100644
--- a/Sources/App/DependencyRegistrator.swift
+++ b/Sources/App/DependencyRegistrator.swift
@@ -137,6 +137,8 @@ struct DependencyRegistrator {
 
         container.register(
             SearchCoordinator(
+                contactsFactory: ContactListController.init,
+                requestsFactory: RequestsContainerController.init,
                 contactFactory: ContactController.init(_:),
                 countriesFactory: CountryListController.init(_:)
             ) as SearchCoordinating)
diff --git a/Sources/Integration/Session/Session.swift b/Sources/Integration/Session/Session.swift
index e14c4901..10617576 100644
--- a/Sources/Integration/Session/Session.swift
+++ b/Sources/Integration/Session/Session.swift
@@ -134,6 +134,12 @@ public final class Session: SessionType {
             email = params.email
         }
 
+        print(report.parameters)
+
+        guard username!.isEmpty == false else {
+            fatalError("Trying to restore an account that has no username")
+        }
+
         try continueInitialization()
 
         if !report.contactIds.isEmpty {
diff --git a/Sources/SearchFeature/Controllers/CameraController.swift b/Sources/SearchFeature/Controllers/CameraController.swift
new file mode 100644
index 00000000..74739208
--- /dev/null
+++ b/Sources/SearchFeature/Controllers/CameraController.swift
@@ -0,0 +1,61 @@
+import Combine
+import AVFoundation
+
+final class CameraController: NSObject {
+    var dataPublisher: AnyPublisher<Data, Never> {
+        dataSubject
+            .receive(on: DispatchQueue.main)
+            .eraseToAnyPublisher()
+    }
+
+    lazy var previewLayer: CALayer = {
+        let layer = AVCaptureVideoPreviewLayer(session: session)
+        layer.videoGravity = .resizeAspectFill
+        return layer
+    }()
+
+    private let session = AVCaptureSession()
+    private let metadataOutput = AVCaptureMetadataOutput()
+    private let dataSubject = PassthroughSubject<Data, Never>()
+
+    override init() {
+        super.init()
+        setupCameraDevice()
+    }
+
+    func start() {
+        guard session.isRunning == false else { return }
+        session.startRunning()
+    }
+
+    func stop() {
+        guard session.isRunning == true else { return }
+        session.stopRunning()
+    }
+
+    private func setupCameraDevice() {
+        if let captureDevice = AVCaptureDevice.default(for: .video),
+           let input = try? AVCaptureDeviceInput(device: captureDevice) {
+
+            if session.canAddInput(input) && session.canAddOutput(metadataOutput) {
+                session.addInput(input)
+                session.addOutput(metadataOutput)
+            }
+
+            metadataOutput.setMetadataObjectsDelegate(self, queue: .main)
+            metadataOutput.metadataObjectTypes = [.qr]
+        }
+    }
+
+    func metadataOutput(
+        _ output: AVCaptureMetadataOutput,
+        didOutput metadataObjects: [AVMetadataObject],
+        from connection: AVCaptureConnection
+    ) {
+        guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
+              let data = object.stringValue?.data(using: .nonLossyASCII), object.type == .qr else { return }
+        dataSubject.send(data)
+    }
+}
+
+extension CameraController: AVCaptureMetadataOutputObjectsDelegate {}
diff --git a/Sources/SearchFeature/Controllers/SearchRightController.swift b/Sources/SearchFeature/Controllers/SearchRightController.swift
index 7aa7dd6a..b9d6f47f 100644
--- a/Sources/SearchFeature/Controllers/SearchRightController.swift
+++ b/Sources/SearchFeature/Controllers/SearchRightController.swift
@@ -1,11 +1,15 @@
 import UIKit
+import Combine
 import DependencyInjection
 
 final class SearchRightController: UIViewController {
+    @Dependency var coordinator: SearchCoordinating
+
     lazy private var screenView = SearchRightView()
 
-//    private let camera = Camera()
-    private var status: SearchQRStatus?
+    private let viewModel = SearchRightViewModel()
+    private var cancellables = Set<AnyCancellable>()
+    private let cameraController = CameraController()
 
     override func loadView() {
         view = screenView
@@ -13,44 +17,70 @@ final class SearchRightController: UIViewController {
 
     override func viewDidLoad() {
         super.viewDidLoad()
-        ///screenView.layer.insertSublayer(camera.previewLayer, at: 0)
-        ///setupBindings()
+        screenView.layer.insertSublayer(cameraController.previewLayer, at: 0)
+        setupBindings()
     }
 
     override func viewDidLayoutSubviews() {
         super.viewDidLayoutSubviews()
-        ///camera.previewLayer.frame = screenView.bounds
+        cameraController.previewLayer.frame = screenView.bounds
     }
 
     override func viewDidAppear(_ animated: Bool) {
         super.viewDidAppear(animated)
-        ///viewModel.resetScanner()
-        ///startCamera()
+        viewModel.viewDidAppear()
     }
 
     override func viewWillDisappear(_ animated: Bool) {
         super.viewWillDisappear(animated)
-//        backgroundScheduler.schedule { [weak self] in
-//            guard let self = self else { return }
-//            self.camera.stop()
-//        }
+        viewModel.viewWillDisappear()
     }
 
-    private func startCamera() {
-//        permissions.requestCamera { [weak self] granted in
-//            guard let self = self else { return }
-//
-//            if granted {
-//                DispatchQueue.main.async { [weak self] in
-//                    guard let self = self else { return }
-//                    self.camera.start()
-//                }
-//            } else {
-//                DispatchQueue.main.async {
-//                    self.status = .failed(.cameraPermission)
-////                    self.screenView.update(with: .failed(.cameraPermission))
-//                }
-//            }
-//        }
+    private func setupBindings() {
+        cameraController
+            .dataPublisher
+            .receive(on: DispatchQueue.main)
+            .sink { [unowned self] in viewModel.didScan(data: $0) }
+            .store(in: &cancellables)
+
+        viewModel.cameraSemaphorePublisher
+            .removeDuplicates()
+            .receive(on: DispatchQueue.global())
+            .sink { [unowned self] setOn in
+                if setOn {
+                    cameraController.start()
+                } else {
+                    cameraController.stop()
+                }
+            }.store(in: &cancellables)
+
+        viewModel.foundPublisher
+            .receive(on: DispatchQueue.main)
+            .delay(for: 1, scheduler: DispatchQueue.main)
+            .sink { [unowned self] in coordinator.toContact($0, from: self) }
+            .store(in: &cancellables)
+
+        viewModel.statusPublisher
+            .receive(on: DispatchQueue.main)
+            .removeDuplicates()
+            .sink { [unowned self] in screenView.update(status: $0) }
+            .store(in: &cancellables)
+
+        screenView.actionButton
+            .publisher(for: .touchUpInside)
+            .receive(on: DispatchQueue.main)
+            .sink { [unowned self] in
+                switch viewModel.statusSubject.value {
+                case .failed(.cameraPermission):
+                    guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
+                    UIApplication.shared.open(url, options: [:])
+                case .failed(.requestOpened):
+                    coordinator.toRequests(from: self)
+                case .failed(.alreadyFriends):
+                    coordinator.toContacts(from: self)
+                default:
+                    break
+                }
+            }.store(in: &cancellables)
     }
 }
diff --git a/Sources/SearchFeature/Coordinator/SearchCoordinator.swift b/Sources/SearchFeature/Coordinator/SearchCoordinator.swift
index 2f66a622..21a9d7b3 100644
--- a/Sources/SearchFeature/Coordinator/SearchCoordinator.swift
+++ b/Sources/SearchFeature/Coordinator/SearchCoordinator.swift
@@ -6,6 +6,8 @@ import Presentation
 import ScrollViewController
 
 public protocol SearchCoordinating {
+    func toRequests(from: UIViewController)
+    func toContacts(from: UIViewController)
     func toContact(_: Contact, from: UIViewController)
     func toDrawer(_: UIViewController, from: UIViewController)
     func toNicknameDrawer(_: UIViewController, from: UIViewController)
@@ -15,21 +17,38 @@ public protocol SearchCoordinating {
 public struct SearchCoordinator {
     var pushPresenter: Presenting = PushPresenter()
     var bottomPresenter: Presenting = BottomPresenter()
+    var replacePresenter: Presenting = ReplacePresenter()
     var fullscreenPresenter: Presenting = FullscreenPresenter()
 
+    var contactsFactory: () -> UIViewController
+    var requestsFactory: () -> UIViewController
     var contactFactory: (Contact) -> UIViewController
     var countriesFactory: (@escaping (Country) -> Void) -> UIViewController
 
     public init(
+        contactsFactory: @escaping () -> UIViewController,
+        requestsFactory: @escaping () -> UIViewController,
         contactFactory: @escaping (Contact) -> UIViewController,
         countriesFactory: @escaping (@escaping (Country) -> Void) -> UIViewController
     ) {
         self.contactFactory = contactFactory
+        self.contactsFactory = contactsFactory
+        self.requestsFactory = requestsFactory
         self.countriesFactory = countriesFactory
     }
 }
 
 extension SearchCoordinator: SearchCoordinating {
+    public func toRequests(from parent: UIViewController) {
+        let screen = requestsFactory()
+        replacePresenter.present(screen, from: parent)
+    }
+
+    public func toContacts(from parent: UIViewController) {
+        let screen = contactsFactory()
+        replacePresenter.present(screen, from: parent)
+    }
+
     public func toContact(_ contact: Contact, from parent: UIViewController) {
         let screen = contactFactory(contact)
         pushPresenter.present(screen, from: parent)
diff --git a/Sources/SearchFeature/ViewModels/SearchRightViewModel.swift b/Sources/SearchFeature/ViewModels/SearchRightViewModel.swift
index 663fb5db..ae8a8d50 100644
--- a/Sources/SearchFeature/ViewModels/SearchRightViewModel.swift
+++ b/Sources/SearchFeature/ViewModels/SearchRightViewModel.swift
@@ -1,14 +1,19 @@
+import Shared
+import Combine
+import XXModels
+import Foundation
 import Permissions
+import Integration
 import DependencyInjection
 
-enum SearchQRStatus: Equatable {
+enum ScanningStatus: Equatable {
     case reading
     case processing
     case success
-    case failed(SearchQRError)
+    case failed(ScanningError)
 }
 
-enum SearchQRError: Equatable {
+enum ScanningError: Equatable {
     case requestOpened
     case unknown(String)
     case cameraPermission
@@ -16,97 +21,91 @@ enum SearchQRError: Equatable {
 }
 
 final class SearchRightViewModel {
-    @Dependency private var permissions: PermissionHandling
-}
+    @Dependency var session: SessionType
+    @Dependency var permissions: PermissionHandling
+
+    var foundPublisher: AnyPublisher<Contact, Never> {
+        foundSubject.eraseToAnyPublisher()
+    }
+
+    var cameraSemaphorePublisher: AnyPublisher<Bool, Never> {
+        cameraSemaphoreSubject.eraseToAnyPublisher()
+    }
+
+    var statusPublisher: AnyPublisher<ScanningStatus, Never> {
+        statusSubject.eraseToAnyPublisher()
+    }
+
+    private let foundSubject = PassthroughSubject<Contact, Never>()
+    private let cameraSemaphoreSubject = PassthroughSubject<Bool, Never>()
+    private(set) var statusSubject = CurrentValueSubject<ScanningStatus, Never>(.reading)
+
+    func viewDidAppear() {
+        permissions.requestCamera { [weak self] granted in
+            guard let self = self else { return }
+
+            if granted {
+                self.statusSubject.value = .reading
+                self.cameraSemaphoreSubject.send(true)
+            } else {
+                self.statusSubject.send(.failed(.cameraPermission))
+            }
+        }
+    }
 
-//
-//
-//import Combine
-//import AVFoundation
-//
-//protocol CameraType {
-//    func start()
-//    func stop()
-//
-//    var previewLayer: CALayer { get }
-//    var dataPublisher: AnyPublisher<Data, Never> { get }
-//}
-//
-//final class Camera: NSObject, CameraType {
-//    var dataPublisher: AnyPublisher<Data, Never> {
-//        dataSubject
-//            .receive(on: DispatchQueue.main)
-//            .eraseToAnyPublisher()
-//    }
-//
-//    lazy var previewLayer: CALayer = {
-//        let layer = AVCaptureVideoPreviewLayer(session: session)
-//        layer.videoGravity = .resizeAspectFill
-//        return layer
-//    }()
-//
-//    private let session = AVCaptureSession()
-//    private let metadataOutput = AVCaptureMetadataOutput()
-//    private let dataSubject = PassthroughSubject<Data, Never>()
-//
-//    override init() {
-//        super.init()
-//        setupCameraDevice()
-//    }
-//
-//    func start() {
-//        guard session.isRunning == false else { return }
-//        session.startRunning()
-//    }
-//
-//    func stop() {
-//        guard session.isRunning == true else { return }
-//        session.stopRunning()
-//    }
-//
-//    private func setupCameraDevice() {
-//        if let captureDevice = AVCaptureDevice.default(for: .video),
-//           let input = try? AVCaptureDeviceInput(device: captureDevice) {
-//
-//            if session.canAddInput(input) && session.canAddOutput(metadataOutput) {
-//                session.addInput(input)
-//                session.addOutput(metadataOutput)
-//            }
-//
-//            metadataOutput.setMetadataObjectsDelegate(self, queue: .main)
-//            metadataOutput.metadataObjectTypes = [.qr]
-//        }
-//    }
-//}
-//
-//extension Camera: AVCaptureMetadataOutputObjectsDelegate {
-//    func metadataOutput(
-//        _ output: AVCaptureMetadataOutput,
-//        didOutput metadataObjects: [AVMetadataObject],
-//        from connection: AVCaptureConnection
-//    ) {
-//        guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
-//              let data = object.stringValue?.data(using: .nonLossyASCII), object.type == .qr else { return }
-//        dataSubject.send(data)
-//    }
-//}
-//
-//final class MockCamera: NSObject, CameraType {
-//    private let dataSubject = PassthroughSubject<Data, Never>()
-//
-//    func start() {
-//        DispatchQueue.global().asyncAfter(deadline: .now() + 2) { [weak self] in
-//            self?.dataSubject.send("###".data(using: .utf8)!)
-//        }
-//    }
-//
-//    func stop() {}
-//
-//    var previewLayer: CALayer { CALayer() }
-//
-//    var dataPublisher: AnyPublisher<Data, Never> {
-//        dataSubject
-//            .receive(on: DispatchQueue.main)
-//            .eraseToAnyPublisher()
-//    }
-//}
+    func viewWillDisappear() {
+        cameraSemaphoreSubject.send(false)
+    }
+
+    func didScan(data: Data) {
+        /// We need to be accepting new readings in order
+        /// to process what just got scanned.
+        ///
+        guard statusSubject.value == .reading else { return }
+        statusSubject.send(.processing)
+
+        /// Whatever got scanned, needs to have id and username
+        /// otherwise is just noise or an unknown qr code
+        ///
+        guard let userId = session.getId(from: data),
+              let username = try? session.extract(fact: .username, from: data) else {
+            let errorTitle = Localized.Scan.Error.invalid
+            statusSubject.send(.failed(.unknown(errorTitle)))
+            return
+        }
+
+        /// Make sure we are not processing a contact
+        /// that we already have
+        ///
+        if let alreadyContact = try? session.dbManager.fetchContacts(.init(id: [userId])).first {
+            /// Show error accordingly to the auth status
+            ///
+            if alreadyContact.authStatus == .friend {
+                statusSubject.send(.failed(.alreadyFriends(username)))
+            } else if [.requested, .verified].contains(alreadyContact.authStatus) {
+                statusSubject.send(.failed(.requestOpened))
+            } else {
+                let generalErrorTitle = Localized.Scan.Error.general
+                statusSubject.send(.failed(.unknown(generalErrorTitle)))
+            }
+
+            return
+        }
+
+        statusSubject.send(.success)
+        cameraSemaphoreSubject.send(false)
+
+        foundSubject.send(.init(
+            id: userId,
+            marshaled: data,
+            username: username,
+            email: try? session.extract(fact: .email, from: data),
+            phone: try? session.extract(fact: .phone, from: data),
+            nickname: nil,
+            photo: nil,
+            authStatus: .stranger,
+            isRecent: false,
+            createdAt: Date()
+        ))
+    }
+}
diff --git a/Sources/SearchFeature/Views/SearchRightView.swift b/Sources/SearchFeature/Views/SearchRightView.swift
index 89dfb813..36380875 100644
--- a/Sources/SearchFeature/Views/SearchRightView.swift
+++ b/Sources/SearchFeature/Views/SearchRightView.swift
@@ -11,7 +11,6 @@ final class SearchRightView: UIView {
 
     init() {
         super.init(frame: .zero)
-
         imageView.contentMode = .center
         actionButton.setStyle(.brandColored)
 
@@ -39,6 +38,67 @@ final class SearchRightView: UIView {
 
     required init?(coder: NSCoder) { nil }
 
+    func update(status: ScanningStatus) {
+        var text: String
+
+        switch status {
+        case .reading, .processing:
+            imageView.isHidden = true
+            actionButton.isHidden = true
+            text = Localized.Scan.Status.reading
+            overlayView.updateCornerColor(Asset.brandPrimary.color)
+
+        case .success:
+            animationView.isHidden = true
+            actionButton.isHidden = true
+            imageView.isHidden = false
+            imageView.image = Asset.sharedSuccess.image
+            text = Localized.Scan.Status.success
+            overlayView.updateCornerColor(Asset.accentSuccess.color)
+
+        case .failed(let error):
+            animationView.isHidden = true
+            imageView.image = Asset.scanError.image
+            imageView.isHidden = false
+            overlayView.updateCornerColor(Asset.accentDanger.color)
+
+            switch error {
+            case .requestOpened:
+                text = Localized.Scan.Error.requested
+                actionButton.setTitle(Localized.Scan.requests, for: .normal)
+                actionButton.isHidden = false
+
+            case .alreadyFriends(let name):
+                text = Localized.Scan.Error.friends(name)
+                actionButton.setTitle(Localized.Scan.contact, for: .normal)
+                actionButton.isHidden = false
+
+            case .cameraPermission:
+                text = Localized.Scan.Error.denied
+                actionButton.setTitle(Localized.Scan.settings, for: .normal)
+                actionButton.isHidden = false
+
+            case .unknown(let content):
+                text = content
+            }
+        }
+
+        let attString = NSMutableAttributedString(string: text)
+        let paragraph = NSMutableParagraphStyle()
+        paragraph.alignment = .center
+        paragraph.lineHeightMultiple = 1.35
+
+        attString.addAttribute(.paragraphStyle, value: paragraph)
+        attString.addAttribute(.foregroundColor, value: Asset.neutralWhite.color)
+        attString.addAttribute(.font, value: Fonts.Mulish.regular.font(size: 14.0) as Any)
+
+        if text.contains("#") {
+            attString.addAttribute(name: .foregroundColor, value: Asset.brandPrimary.color, betweenCharacters: "#")
+        }
+
+        statusLabel.attributedText = attString
+    }
+
     private func setupConstraints() {
         overlayView.snp.makeConstraints {
             $0.top.equalToSuperview()
-- 
GitLab