diff --git a/Sources/App/DependencyRegistrator.swift b/Sources/App/DependencyRegistrator.swift index 87b8e7dd9d903fbf11f686ac46ecb730fac4eef7..2d5abdec492a39f9eac25be51fddcafa11a7d0e7 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 e14c4901e1a22fe97869768b154ec25dd70eff93..10617576074d5a8a33bb7cdb220fde997b8e849d 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 0000000000000000000000000000000000000000..7473920844e51bf6b77634818ee56282cb00b5d2 --- /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 7aa7dd6ad19821e28c19247dc419715baab9e35d..b9d6f47fac6aec948e4d6a17985dda42e1f36352 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 2f66a6228fd96bb156266a80c16a9ac5ba53c5d9..21a9d7b3d5eb9b1cef283a410135157fb7571ef1 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 663fb5db6d751097dba8843a7400484bcbc874db..ae8a8d506d654e9463aee6a318c12bf866c168cf 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 89dfb81309b315f3d4a401708fee2fd038806138..363808754852969a75c3c811836faa9b40455034 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()