diff --git a/Sources/SearchFeature/Controllers/SearchQRController.swift b/Sources/SearchFeature/Controllers/SearchQRController.swift index 04fe440f0ab5c3b3753f0758c2e58aa7edaa1432..49718728ad83faa1ba6eda74eb48c2d46e60392f 100644 --- a/Sources/SearchFeature/Controllers/SearchQRController.swift +++ b/Sources/SearchFeature/Controllers/SearchQRController.swift @@ -1,9 +1,164 @@ import UIKit +import Permissions +import DependencyInjection + +enum SearchQRStatus: Equatable { + case reading + case processing + case success + case failed(SearchQRError) +} + +enum SearchQRError: Equatable { + case requestOpened + case unknown(String) + case cameraPermission + case alreadyFriends(String) +} final class SearchQRController: UIViewController { + @Dependency private var permissions: PermissionHandling + lazy private var screenView = SearchQRView() + private let camera = Camera() + private var status: SearchQRStatus? + override func loadView() { view = screenView } + + override func viewDidLoad() { + super.viewDidLoad() + ///screenView.layer.insertSublayer(camera.previewLayer, at: 0) + ///setupBindings() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + ///camera.previewLayer.frame = screenView.bounds + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + ///viewModel.resetScanner() + ///startCamera() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) +// backgroundScheduler.schedule { [weak self] in +// guard let self = self else { return } +// self.camera.stop() +// } + } + + 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)) + } + } + } + } +} + + +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() + } } diff --git a/Sources/SearchFeature/Views/OverlayView.swift b/Sources/SearchFeature/Views/OverlayView.swift new file mode 100644 index 0000000000000000000000000000000000000000..8242857716936fe49cf21a59bad280195c726165 --- /dev/null +++ b/Sources/SearchFeature/Views/OverlayView.swift @@ -0,0 +1,181 @@ +import UIKit +import Shared + +final class OverlayView: UIView { + private let cropView = UIView() + private let scanViewLength = 266.0 + private let maskLayer = CAShapeLayer() + private let topLeftLayer = CAShapeLayer() + private let topRightLayer = CAShapeLayer() + private let bottomLeftLayer = CAShapeLayer() + private let bottomRightLayer = CAShapeLayer() + + init() { + super.init(frame: .zero) + backgroundColor = Asset.neutralDark.color.withAlphaComponent(0.5) + + addSubview(cropView) + + cropView.snp.makeConstraints { + $0.width.equalTo(scanViewLength) + $0.centerY.equalToSuperview().offset(-50) + $0.centerX.equalToSuperview() + $0.height.equalTo(scanViewLength) + } + + maskLayer.fillRule = .evenOdd + layer.mask = maskLayer + layer.masksToBounds = true + + [topLeftLayer, topRightLayer, bottomLeftLayer, bottomRightLayer].forEach { + $0.strokeColor = Asset.brandPrimary.color.cgColor + $0.fillColor = UIColor.clear.cgColor + $0.lineWidth = 3.0 + $0.lineCap = .round + layer.addSublayer($0) + } + } + + required init?(coder: NSCoder) { nil } + + override func layoutSubviews() { + super.layoutSubviews() + + maskLayer.frame = bounds + let path = UIBezierPath(rect: bounds) + path.append(UIBezierPath(roundedRect: cropView.frame, cornerRadius: 30.0)) + maskLayer.path = path.cgPath + + topLeftLayer.frame = bounds + topRightLayer.frame = bounds + bottomRightLayer.frame = bounds + bottomLeftLayer.frame = bounds + + topLeftLayer.path = topLeftPath() + topRightLayer.path = topRightPath() + bottomRightLayer.path = bottomRightPath() + bottomLeftLayer.path = bottomLeftPath() + } + + func updateCornerColor(_ color: UIColor) { + [topLeftLayer, topRightLayer, bottomLeftLayer, bottomRightLayer].forEach { + $0.strokeColor = color.cgColor + } + } + + func topLeftPath() -> CGPath { + let path = UIBezierPath() + + let vert0X = cropView.frame.minX - 15 + let vert0Y = cropView.frame.minY + 45 + let vert0 = CGPoint(x: vert0X, y: vert0Y) + path.move(to: vert0) + + let vertNX = cropView.frame.minX - 15 + let vertNY = cropView.frame.minY + 15 + let vertN = CGPoint(x: vertNX, y: vertNY) + path.addLine(to: vertN) + + let arcCenterX = cropView.frame.minX + 15 + let arcCenterY = cropView.frame.minY + 15 + let arcCenter = CGPoint(x: arcCenterX , y: arcCenterY) + path.addArc(center: arcCenter, startAngle: .pi) + + let horizX = cropView.frame.minX + 45 + let horizY = cropView.frame.minY - 15 + let horiz = CGPoint(x: horizX, y: horizY) + path.addLine(to: horiz) + + return path.cgPath + } + + func topRightPath() -> CGPath { + let path = UIBezierPath() + + let horiz0X = cropView.frame.maxX - 45 + let horiz0Y = cropView.frame.minY - 15 + let horiz0 = CGPoint(x: horiz0X, y: horiz0Y) + path.move(to: horiz0) + + let horizNX = cropView.frame.maxX - 15 + let horizNY = cropView.frame.minY - 15 + let horizN = CGPoint(x: horizNX, y: horizNY) + path.addLine(to: horizN) + + let arcCenterX = cropView.frame.maxX - 15 + let arcCenterY = cropView.frame.minY + 15 + let arcCenter = CGPoint(x: arcCenterX, y: arcCenterY) + path.addArc(center: arcCenter, startAngle: 3 * .pi/2) + + let vertX = cropView.frame.maxX + 15 + let vertY = cropView.frame.minY + 45 + let vert = CGPoint(x: vertX, y: vertY) + path.addLine(to: vert) + + return path.cgPath + } + + func bottomRightPath() -> CGPath { + let path = UIBezierPath() + + let vert0X = cropView.frame.maxX + 15 + let vert0Y = cropView.frame.maxY - 45 + let vert0 = CGPoint(x: vert0X, y: vert0Y) + path.move(to: vert0) + + let vertNX = cropView.frame.maxX + 15 + let vertNY = cropView.frame.maxY - 15 + let vertN = CGPoint(x: vertNX, y: vertNY) + path.addLine(to: vertN) + + let arcCenterX = cropView.frame.maxX - 15 + let arcCenterY = cropView.frame.maxY - 15 + let arcCenter = CGPoint(x: arcCenterX, y: arcCenterY) + path.addArc(center: arcCenter, startAngle: 0) + + let horizX = cropView.frame.maxX - 45 + let horizY = cropView.frame.maxY + 15 + let horiz = CGPoint(x: horizX, y: horizY) + path.addLine(to: horiz) + + return path.cgPath + } + + func bottomLeftPath() -> CGPath { + let path = UIBezierPath() + + let horiz0X = cropView.frame.minX + 45 + let horiz0Y = cropView.frame.maxY + 15 + let horiz0 = CGPoint(x: horiz0X, y: horiz0Y) + path.move(to: horiz0) + + let horizNX = cropView.frame.minX + 15 + let horizNY = cropView.frame.maxY + 15 + let horizN = CGPoint(x: horizNX, y: horizNY) + path.addLine(to: horizN) + + let arcCenterX = cropView.frame.minX + 15 + let arcCenterY = cropView.frame.maxY - 15 + let arcCenter = CGPoint(x: arcCenterX, y: arcCenterY) + path.addArc(center: arcCenter, startAngle: .pi/2) + + let vertX = cropView.frame.minX - 15 + let vertY = cropView.frame.maxY - 45 + let vert = CGPoint(x: vertX, y: vertY) + path.addLine(to: vert) + + return path.cgPath + } +} + +private extension UIBezierPath { + func addArc(center: CGPoint, startAngle: CGFloat) { + addArc( + withCenter: center, + radius: 30, + startAngle: startAngle, + endAngle: startAngle + .pi/2, + clockwise: true + ) + } +} diff --git a/Sources/SearchFeature/Views/SearchEmailView.swift b/Sources/SearchFeature/Views/SearchEmailView.swift index 053c62592714b4822b94e063d3747d1a693b141f..8110ec4bbeeda5d7a06c453675cfc204f95546f9 100644 --- a/Sources/SearchFeature/Views/SearchEmailView.swift +++ b/Sources/SearchFeature/Views/SearchEmailView.swift @@ -1,17 +1,15 @@ import UIKit import Shared -import InputField final class SearchEmailView: UIView { - let inputField = InputField() + let inputField = SearchComponent() init() { super.init(frame: .zero) - inputField.setup( - style: .regular, - title: "Email", - placeholder: "Email" + inputField.set( + placeholder: Localized.Ud.Search.Email.input, + imageAtRight: nil ) addSubview(inputField) diff --git a/Sources/SearchFeature/Views/SearchPhoneView.swift b/Sources/SearchFeature/Views/SearchPhoneView.swift index 1868cec6960091bcf08f9294740d8121b1a41bca..9b086bcfc8c33761efd54aad852a001204a2c239 100644 --- a/Sources/SearchFeature/Views/SearchPhoneView.swift +++ b/Sources/SearchFeature/Views/SearchPhoneView.swift @@ -1,17 +1,15 @@ import UIKit import Shared -import InputField final class SearchPhoneView: UIView { - let inputField = InputField() + let inputField = SearchComponent() init() { super.init(frame: .zero) - inputField.setup( - style: .regular, - title: "Phone", - placeholder: "Phone" + inputField.set( + placeholder: Localized.Ud.Search.Phone.input, + imageAtRight: nil ) addSubview(inputField) diff --git a/Sources/SearchFeature/Views/SearchQRView.swift b/Sources/SearchFeature/Views/SearchQRView.swift index 4adae04506dd7f525df2563018aeedb3dba37b10..24278ce7902fc59719da416a719084a50d79f35c 100644 --- a/Sources/SearchFeature/Views/SearchQRView.swift +++ b/Sources/SearchFeature/Views/SearchQRView.swift @@ -1,28 +1,56 @@ import UIKit import Shared -import InputField final class SearchQRView: UIView { - let inputField = InputField() + let statusLabel = UILabel() + let imageView = UIImageView() + let stackView = UIStackView() + let animationView = DotAnimation() + let overlayView = OverlayView() + let actionButton = CapsuleButton() init() { super.init(frame: .zero) - inputField.setup( - style: .regular, - title: "QR", - placeholder: "QR" - ) + imageView.contentMode = .center + actionButton.setStyle(.brandColored) - addSubview(inputField) + statusLabel.numberOfLines = 0 + statusLabel.textAlignment = .center + statusLabel.textColor = Asset.neutralWhite.color + statusLabel.font = Fonts.Mulish.regular.font(size: 14.0) - inputField.snp.makeConstraints { - $0.top.equalToSuperview().offset(15) - $0.left.equalToSuperview().offset(15) - $0.right.equalToSuperview().offset(-15) - $0.bottom.lessThanOrEqualToSuperview() - } + stackView.spacing = 15 + stackView.axis = .vertical + stackView.addArrangedSubview(animationView) + stackView.addArrangedSubview(imageView) + stackView.addArrangedSubview(statusLabel) + stackView.addArrangedSubview(actionButton) + + imageView.isHidden = true + actionButton.isHidden = true + animationView.isHidden = false + + addSubview(overlayView) + addSubview(stackView) + + setupConstraints() } required init?(coder: NSCoder) { nil } + + private func setupConstraints() { + overlayView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + } + + stackView.snp.makeConstraints { + $0.left.equalToSuperview().offset(57) + $0.right.equalToSuperview().offset(-57) + $0.bottom.equalTo(safeAreaLayoutGuide).offset(-100) + } + } } diff --git a/Sources/SearchFeature/Views/SearchUsernamePlaceholderView.swift b/Sources/SearchFeature/Views/SearchUsernamePlaceholderView.swift index 8b249aac7ed1e4abf255686e63030fcec351f2ab..aa8843c47c3aea291f5e5a3f2fe10ef874c30afb 100644 --- a/Sources/SearchFeature/Views/SearchUsernamePlaceholderView.swift +++ b/Sources/SearchFeature/Views/SearchUsernamePlaceholderView.swift @@ -16,7 +16,7 @@ final class SearchUsernamePlaceholderView: UIView { super.init(frame: .zero) let attrString = NSMutableAttributedString( - string: Localized.Ud.Username.Search.Placeholder.title, + string: Localized.Ud.Search.Username.Placeholder.title, attributes: [ .foregroundColor: Asset.neutralDark.color, .font: Fonts.Mulish.bold.font(size: 32.0) @@ -36,7 +36,7 @@ final class SearchUsernamePlaceholderView: UIView { paragraph.lineHeightMultiple = 1.3 subtitleWithInfo.setup( - text: Localized.Ud.Username.Search.Placeholder.subtitle, + text: Localized.Ud.Search.Username.Placeholder.subtitle, attributes: [ .paragraphStyle: paragraph, .foregroundColor: Asset.neutralBody.color, diff --git a/Sources/SearchFeature/Views/SearchUsernameView.swift b/Sources/SearchFeature/Views/SearchUsernameView.swift index f8c562237e0957b1d0de12dc5093de76735d2736..e6de2ce0856a7592522702e6393f6cc402153c28 100644 --- a/Sources/SearchFeature/Views/SearchUsernameView.swift +++ b/Sources/SearchFeature/Views/SearchUsernameView.swift @@ -1,6 +1,5 @@ import UIKit import Shared -import InputField final class SearchUsernameView: UIView { let inputField = SearchComponent() @@ -10,7 +9,7 @@ final class SearchUsernameView: UIView { super.init(frame: .zero) inputField.set( - placeholder: Localized.Ud.Username.Search.inputPlaceholder, + placeholder: Localized.Ud.Search.Username.input, imageAtRight: nil ) diff --git a/Sources/Shared/AutoGenerated/Strings.swift b/Sources/Shared/AutoGenerated/Strings.swift index 2265b055612f17dd882b8790597ae15f94272edd..0117c9b8d9abd5c6a51d004886c649c491e3e918 100644 --- a/Sources/Shared/AutoGenerated/Strings.swift +++ b/Sources/Shared/AutoGenerated/Strings.swift @@ -1205,8 +1205,6 @@ public enum Localized { public static func noneFound(_ p1: Any) -> String { return Localized.tr("Localizable", "ud.noneFound", String(describing: p1)) } - /// User - public static let sectionTitle = Localized.tr("Localizable", "ud.sectionTitle") /// Search public static let title = Localized.tr("Localizable", "ud.title") public enum NicknameDrawer { @@ -1218,8 +1216,6 @@ public enum Localized { public static let title = Localized.tr("Localizable", "ud.nicknameDrawer.title") } public enum Placeholder { - /// Searching is private by nature. The network cannot identify who a search request came from. - public static let title = Localized.tr("Localizable", "ud.placeholder.title") public enum Drawer { /// Got it public static let action = Localized.tr("Localizable", "ud.placeholder.drawer.action") @@ -1241,6 +1237,26 @@ public enum Localized { /// Request Contact public static let title = Localized.tr("Localizable", "ud.requestDrawer.title") } + public enum Search { + public enum Email { + /// Search by email + public static let input = Localized.tr("Localizable", "ud.search.email.input") + } + public enum Phone { + /// Search by phone number + public static let input = Localized.tr("Localizable", "ud.search.phone.input") + } + public enum Username { + /// Search by username + public static let input = Localized.tr("Localizable", "ud.search.username.input") + public enum Placeholder { + /// Your searches are anonymous. Search information is never linked to your account or personally identifiable. + public static let subtitle = Localized.tr("Localizable", "ud.search.username.placeholder.subtitle") + /// Search for #friends# anonymously, add them to your #connections# to start a completely private messaging channel. + public static let title = Localized.tr("Localizable", "ud.search.username.placeholder.title") + } + } + } public enum Tab { /// Email public static let email = Localized.tr("Localizable", "ud.tab.email") @@ -1251,18 +1267,6 @@ public enum Localized { /// Username public static let username = Localized.tr("Localizable", "ud.tab.username") } - public enum Username { - public enum Search { - /// Search by username - public static let inputPlaceholder = Localized.tr("Localizable", "ud.username.search.inputPlaceholder") - public enum Placeholder { - /// Your searches are anonymous. Search information is never linked to your account or personally identifiable. - public static let subtitle = Localized.tr("Localizable", "ud.username.search.placeholder.subtitle") - /// Search for #friends# anonymously, add them to your #connections# to start a completely private messaging channel. - public static let title = Localized.tr("Localizable", "ud.username.search.placeholder.title") - } - } - } } public enum Validator { diff --git a/Sources/Shared/Resources/en.lproj/Localizable.strings b/Sources/Shared/Resources/en.lproj/Localizable.strings index e16bf6fa254602a9e7f4cfaa9f977af25490517f..fb5ab0e271d09d6da94605606504f46ba9a757df 100644 --- a/Sources/Shared/Resources/en.lproj/Localizable.strings +++ b/Sources/Shared/Resources/en.lproj/Localizable.strings @@ -950,24 +950,20 @@ "ud.title" = "Search"; -"ud.placeholder.title" -= "Searching is private by nature. The network cannot identify who a search request came from."; +"ud.tab.username" += "Username"; +"ud.tab.email" += "Email"; +"ud.tab.phone" += "Phone"; +"ud.tab.qr" += "QR Code"; "ud.placeholder.drawer.title" = "Search"; "ud.placeholder.drawer.subtitle" = "You can search for users by their username, email, or phone number using the xx network’s #Anonymous Data Retrieval protocol# which keeps a user’s identity anonymous while requesting data. All sent requests contain salted hashes of what you are searching for. Raw data on emails, usernames, and phone numbers do not leave your phone."; "ud.placeholder.drawer.action" = "Got it"; -"ud.tab.phone" -= "Phone"; -"ud.tab.email" -= "Email"; -"ud.tab.username" -= "Username"; -"ud.tab.qr" -= "QR Code"; -"ud.sectionTitle" -= "User"; "ud.noneFound" = "There are no users with that %@."; "ud.requestDrawer.title" @@ -986,12 +982,16 @@ = "Edit your new contact’s nickname so you know who they are."; "ud.nicknameDrawer.save" = "Save"; - -"ud.username.search.inputPlaceholder" +"ud.search.username.input" = "Search by username"; -"ud.username.search.placeholder.title" +"ud.search.email.input" += "Search by email"; +"ud.search.phone.input" += "Search by phone number"; + +"ud.search.username.placeholder.title" = "Search for #friends# anonymously, add them to your #connections# to start a completely private messaging channel."; -"ud.username.search.placeholder.subtitle" +"ud.search.username.placeholder.subtitle" = "Your searches are anonymous. Search information is never linked to your account or personally identifiable."; // LaunchFeature