From 805ebbe7b8dd200b558ef81a92f01366faed9b9c Mon Sep 17 00:00:00 2001 From: ckost9n Date: Thu, 5 Feb 2026 17:17:36 +0300 Subject: [PATCH 1/3] Add qr scan --- Friendly/Sources/Localizable.xcstrings | 208 ++++++++++++++++++ Friendly/Sources/NetworkView.swift | 12 + Friendly/Sources/NetworkViewModel.swift | 13 ++ Friendly/Sources/ScannerQr/CameraView.swift | 32 +++ .../ScannerQr/QRScannerCameraView.swift | 193 ++++++++++++++++ .../QRScannerPermissionViewModel.swift | 58 +++++ .../ScannerQr/QRScannerViewModel.swift | 83 +++++++ .../Sources/ScannerQr/QRSessionService.swift | 101 +++++++++ .../Sources/ScannerQr/QrScannerDelegate.swift | 21 ++ Friendly/Sources/ScannerQr/ScanView.swift | 105 +++++++++ .../Sources/ScannerQr/ScanViewModel.swift | 70 ++++++ 11 files changed, 896 insertions(+) create mode 100644 Friendly/Sources/ScannerQr/CameraView.swift create mode 100644 Friendly/Sources/ScannerQr/QRScannerCameraView.swift create mode 100644 Friendly/Sources/ScannerQr/QRScannerPermissionViewModel.swift create mode 100644 Friendly/Sources/ScannerQr/QRScannerViewModel.swift create mode 100644 Friendly/Sources/ScannerQr/QRSessionService.swift create mode 100644 Friendly/Sources/ScannerQr/QrScannerDelegate.swift create mode 100644 Friendly/Sources/ScannerQr/ScanView.swift create mode 100644 Friendly/Sources/ScannerQr/ScanViewModel.swift diff --git a/Friendly/Sources/Localizable.xcstrings b/Friendly/Sources/Localizable.xcstrings index 22722c0..216ca02 100644 --- a/Friendly/Sources/Localizable.xcstrings +++ b/Friendly/Sources/Localizable.xcstrings @@ -18,6 +18,54 @@ } } }, + "button_base_close" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть" + } + } + } + }, + "error_base_subtite" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try again" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Повторите еще" + } + } + } + }, + "error_base_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка" + } + } + } + }, "feed_empty" : { "extractionState" : "manual", "localizations" : { @@ -443,6 +491,166 @@ } } }, + "scan_enter_info_subtitle" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scanning is required to verify access and link the account." + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Сканирование нужно, чтобы подтвердить доступ и связать аккаунт." + } + } + } + }, + "scan_enter_info_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "To use the app, scan another user's QR code" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Для использования приложения nотсканируйте qr код другого пользователя" + } + } + } + }, + "scan_enter_open_scanner" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scan QR" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Сканировать QR" + } + } + } + }, + "scan_enter_scanning_subtitle" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This may take a few seconds" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Это может занять пару секунд" + } + } + } + }, + "scan_enter_scanning_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checking QR…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Проверяем QR…" + } + } + } + }, + "scanner_qrcode_camera_permission_message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "To scan a QR code, please allow camera access in your device settings." + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Для сканирования QR-кода разрешите доступ к камере в настройках устройства" + } + } + } + }, + "scanner_qrcode_camera_permission_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera access required" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Нет доступа к камере" + } + } + } + }, + "scanner_qrcode_navigation_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "QR Scanning" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Сканирование QR" + } + } + } + }, + "scanner_qrcode_subtitle" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scanning will start automatically" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Сканирование начнётся автоматически" + } + } + } + }, + "scanner_qrcode_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Place the QR code inside the scanning area" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Поместите QR-код в область сканирования" + } + } + } + }, "sign_up_description" : { "extractionState" : "manual", "localizations" : { diff --git a/Friendly/Sources/NetworkView.swift b/Friendly/Sources/NetworkView.swift index e920bdd..2552e24 100644 --- a/Friendly/Sources/NetworkView.swift +++ b/Friendly/Sources/NetworkView.swift @@ -33,6 +33,11 @@ struct NetworkView: View { ) { Image(systemName: "qrcode") } + Button( + action: { viewModel.findQRCode() }, + ) { + Image(systemName: "qrcode.viewfinder") + } } } .navigationTitle("network_friends_title") @@ -43,6 +48,13 @@ struct NetworkView: View { onDismiss: { viewModel.shouldShowQRCode = false }, ) } + .sheet( + isPresented: $viewModel.shouldFindQRCode, + ) { + ScanToUseAppView( + onSuccess: { _ in viewModel.shouldFindQRCode = false }, + ) + } .onAppear { viewModel.appear() } .navigationDestination( for: NetworkViewModel.ProfileDestination.self, diff --git a/Friendly/Sources/NetworkViewModel.swift b/Friendly/Sources/NetworkViewModel.swift index 90e3fc7..13a16ad 100644 --- a/Friendly/Sources/NetworkViewModel.swift +++ b/Friendly/Sources/NetworkViewModel.swift @@ -16,6 +16,15 @@ class NetworkViewModel { } } } + var shouldFindQRCode: Bool = false { + didSet { + if !shouldFindQRCode { + Task { + await reload() + } + } + } + } let router: Router @@ -27,6 +36,10 @@ class NetworkViewModel { shouldShowQRCode = true } + func findQRCode() { + shouldFindQRCode = true + } + func appear() { Task { await reload() diff --git a/Friendly/Sources/ScannerQr/CameraView.swift b/Friendly/Sources/ScannerQr/CameraView.swift new file mode 100644 index 0000000..3c9193d --- /dev/null +++ b/Friendly/Sources/ScannerQr/CameraView.swift @@ -0,0 +1,32 @@ +// +// CameraView.swift +// Friendly +// +// Created by Konstantin on 04.02.2026. +// + +import SwiftUI +import AVKit + +struct CameraView: UIViewRepresentable { + var frameSize: CGSize + + @Binding var session: AVCaptureSession + + func makeUIView(context: Context) -> UIView { + let view = UIViewType(frame: CGRect(origin: .zero, size: frameSize)) + view.backgroundColor = .clear + + let cameraLayer = AVCaptureVideoPreviewLayer(session: session) + cameraLayer.frame = CGRect(origin: .zero, size: frameSize) + cameraLayer.videoGravity = .resizeAspectFill + cameraLayer.masksToBounds = true + view.layer.addSublayer(cameraLayer) + + return view + } + + func updateUIView(_ uiView: UIViewType, context: Context) { + + } +} diff --git a/Friendly/Sources/ScannerQr/QRScannerCameraView.swift b/Friendly/Sources/ScannerQr/QRScannerCameraView.swift new file mode 100644 index 0000000..ca0206c --- /dev/null +++ b/Friendly/Sources/ScannerQr/QRScannerCameraView.swift @@ -0,0 +1,193 @@ +// +// QRScannerView.swift +// Friendly +// +// Created by Konstantin on 05.02.2026. +// + +import SwiftUI +import AVFoundation + +struct QRScannerCameraView: View { + @Environment(\.openURL) private var openURL + + @StateObject private var qrDelegate = QrScannerDelegate() + @StateObject private var viewModel: QRScannerViewModel + + @State private var isScanningAnimation = false + + init(onDismiss: @escaping (String?) -> Void) { + let delegate = QrScannerDelegate() + _qrDelegate = StateObject(wrappedValue: delegate) + _viewModel = StateObject(wrappedValue: QRScannerViewModel( + qrDelegate: delegate, + onDismiss: onDismiss + )) + } + + var body: some View { + NavigationView { + ZStack { + switch viewModel.state { + case .idle, .loading: + ProgressView() + .onAppear { viewModel.prepare() } + + case .running: + scanView() + + case .noPermission: + permissionView() + + case .ioError: + errorView() + } + } + .toolbar { + ToolbarItem(placement: .principal) { + Text("scanner_qrcode_navigation_title") + } + ToolbarItem(placement: .primaryAction) { + Button(action: { viewModel.onDismiss(code: nil) }) { + Image(systemName: "xmark") + } + } + } + .onDisappear { + viewModel.stop() + } + .onChange(of: qrDelegate.scanndeCode) { _, newValue in + guard let code = newValue else { return } + viewModel.didScan(code: code) + stopScannerAnimation() + } + } + } + + // MARK: - Views + + private func scanView() -> some View { + VStack(spacing: 8) { + Text("scanner_qrcode_title") + .font(.title2) + .padding(.top, 20) + + Text("scanner_qrcode_subtitle") + .font(.subheadline) + .foregroundStyle(.secondary) + + Spacer(minLength: .zero) + + GeometryReader { proxy in + let size = proxy.size + + ZStack { + CameraView( + frameSize: CGSize(width: size.width, height: size.width), + session: viewModel.sessionBinding // см. ниже + ) + .scaleEffect(0.97) + + ForEach(0...4, id: \.self) { index in + let rotation = Double(index) * 90.0 + RoundedRectangle(cornerRadius: 2, style: .circular) + .trim(from: 0.61, to: 0.64) + .stroke( + Color.blue, + style: StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round) + ) + .rotationEffect(.degrees(rotation)) + } + } + .frame(width: size.width, height: size.width) + .overlay(alignment: .top) { + Rectangle() + .fill(Color.blue) + .frame(height: 2.5) + .shadow( + color: .black.opacity(0.8), + radius: 8, + x: 0, + y: isScanningAnimation ? 15 : -15 + ) + .offset(y: isScanningAnimation ? size.width : 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { startScannerAnimation() } + } + .padding(.horizontal, 45) + + Spacer(minLength: 15) + } + .padding(15) + } + + private func permissionView() -> some View { + VStack(spacing: 12) { + Text("scanner_qrcode_camera_permission_title") + .font(.title2) + + Text("scanner_qrcode_camera_permission_message") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button("open_settings") { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + openURL(url) + } + .buttonStyle(.borderedProminent) + + Button("", role: .cancel) { viewModel.onDismiss(code: nil) } + .buttonStyle(.glass) + .padding(.horizontal) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func errorView() -> some View { + VStack(spacing: 12) { + Text("error_base_title") + .font(.title2) + + Button { + viewModel.prepare() + } label: { + Text("error_base_subtite") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .buttonStyle(.glassProminent) + .padding(.horizontal) + + Button { + viewModel.onDismiss(code: nil) + } label: { + Text("button_base_close") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .buttonStyle(.glass) + .padding(.horizontal) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Animation + + private func startScannerAnimation() { + guard !isScanningAnimation else { return } + withAnimation(.easeInOut(duration: 0.85).delay(0.1).repeatForever(autoreverses: true)) { + isScanningAnimation = true + } + } + + private func stopScannerAnimation() { + withAnimation(.easeInOut(duration: 0.25)) { + isScanningAnimation = false + } + } +} diff --git a/Friendly/Sources/ScannerQr/QRScannerPermissionViewModel.swift b/Friendly/Sources/ScannerQr/QRScannerPermissionViewModel.swift new file mode 100644 index 0000000..670ac6b --- /dev/null +++ b/Friendly/Sources/ScannerQr/QRScannerPermissionViewModel.swift @@ -0,0 +1,58 @@ +// +// QRScannerPermissionViewModel.swift +// Friendly +// +// Created by Konstantin on 05.02.2026. +// + +import AVFoundation + +final class CameraPermissionService { + enum CameraAccessState: Equatable { + case authorized + case notDetermined + case denied + case restricted + } + + func status() -> AVAuthorizationStatus { + AVCaptureDevice.authorizationStatus(for: .video) + } + + func request() async -> Bool { + await AVCaptureDevice.requestAccess(for: .video) + } +} + +// Если нужно будет проверить доступы камеры до перехода на экран сканирования +@MainActor +final class QRScannerPermissionViewModel: ObservableObject { + + @Published var showScanner = false + @Published var showSettingsAlert = false + + private let cameraPermissionService = CameraPermissionService() + + func handleScanTap() { + Task { + switch cameraPermissionService.status() { + case .authorized: + showScanner = true + + case .notDetermined: + let granted = await cameraPermissionService.request() + if granted { + showScanner = true + } else { + showSettingsAlert = true + } + + case .denied, .restricted: + showSettingsAlert = true + + @unknown default: + showSettingsAlert = true + } + } + } +} diff --git a/Friendly/Sources/ScannerQr/QRScannerViewModel.swift b/Friendly/Sources/ScannerQr/QRScannerViewModel.swift new file mode 100644 index 0000000..df5a295 --- /dev/null +++ b/Friendly/Sources/ScannerQr/QRScannerViewModel.swift @@ -0,0 +1,83 @@ +// +// QRScannerViewModel.swift +// Friendly +// +// Created by Konstantin on 05.02.2026. +// + +import SwiftUI +import AVFoundation + +@MainActor +final class QRScannerViewModel: ObservableObject { + + enum State { + case idle + case loading + case running + case noPermission + case ioError + } + + @Published var state: State = .idle + @Published var session: AVCaptureSession? + + let qrOutput = AVCaptureMetadataOutput() + + private let service = QRSessionService() + private let qrDelegate: AVCaptureMetadataOutputObjectsDelegate + private let onDismiss: (String?) -> Void + + init(qrDelegate: AVCaptureMetadataOutputObjectsDelegate, + onDismiss: @escaping (String?) -> Void) { + self.qrDelegate = qrDelegate + self.onDismiss = onDismiss + } + + var sessionBinding: Binding { + Binding( + get: { self.session ?? AVCaptureSession() }, + set: { _ in /* session не меняем из View */ } + ) + } + + func prepare() { + Task { + state = .loading + do { + let configuredSession = try await service.makeSession( + metadataOutput: qrOutput, + delegate: qrDelegate, + delegateQueue: .main + ) + self.session = configuredSession + service.startRunning(configuredSession) + state = .running + } catch let err as CameraSessionError { + switch err { + case .permissionDenied: + state = .noPermission + default: + state = .ioError + } + } catch { + state = .ioError + } + } + } + + func stop() { + guard let session else { return } + service.stopRunning(session) + } + + func didScan(code: String) { + stop() + onDismiss(code) + } + + func onDismiss(code: String?) { + stop() + onDismiss(code) + } +} diff --git a/Friendly/Sources/ScannerQr/QRSessionService.swift b/Friendly/Sources/ScannerQr/QRSessionService.swift new file mode 100644 index 0000000..32b8b37 --- /dev/null +++ b/Friendly/Sources/ScannerQr/QRSessionService.swift @@ -0,0 +1,101 @@ +// +// QRSessionService.swift +// Friendly +// +// Created by Konstantin on 05.02.2026. +// + +import AVFoundation + +enum CameraSessionError: Error, Equatable { + case permissionDenied + case noBackCamera + case cannotAddInput + case cannotAddOutput + case configurationFailed +} + +final class QRSessionService { + private let sessionQueue = DispatchQueue(label: "qr.session.queue") + private let permissionService: CameraPermissionService + + init(permissionService: CameraPermissionService = .init()) { + self.permissionService = permissionService + } + + private func runOnSessionQueue(_ work: @escaping () throws -> T) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + sessionQueue.async { + do { continuation.resume(returning: try work()) } + catch { continuation.resume(throwing: error) } + } + } + } +} + +extension QRSessionService { + func makeSession( + metadataOutput: AVCaptureMetadataOutput, + delegate: AVCaptureMetadataOutputObjectsDelegate, + delegateQueue: DispatchQueue = .main + ) async throws -> AVCaptureSession { + + switch permissionService.status() { + case .authorized: + break + case .notDetermined: + let granted = await permissionService.request() + if !granted { throw CameraSessionError.permissionDenied } + case .denied, .restricted: + throw CameraSessionError.permissionDenied + @unknown default: + throw CameraSessionError.permissionDenied + } + + return try await runOnSessionQueue { + let session = AVCaptureSession() + session.beginConfiguration() + defer { session.commitConfiguration() } + + session.sessionPreset = .high + + guard let device = AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInWideAngleCamera], + mediaType: .video, + position: .back + ).devices.first else { + throw CameraSessionError.noBackCamera + } + + let input = try AVCaptureDeviceInput(device: device) + guard session.canAddInput(input) else { + throw CameraSessionError.cannotAddInput + } + session.addInput(input) + + guard session.canAddOutput(metadataOutput) else { + throw CameraSessionError.cannotAddOutput + } + session.addOutput(metadataOutput) + + metadataOutput.setMetadataObjectsDelegate(delegate, queue: delegateQueue) + metadataOutput.metadataObjectTypes = [.qr] + + return session + } + } + + func startRunning(_ session: AVCaptureSession) { + sessionQueue.async { + guard !session.isRunning else { return } + session.startRunning() + } + } + + func stopRunning(_ session: AVCaptureSession) { + sessionQueue.async { + guard session.isRunning else { return } + session.stopRunning() + } + } +} diff --git a/Friendly/Sources/ScannerQr/QrScannerDelegate.swift b/Friendly/Sources/ScannerQr/QrScannerDelegate.swift new file mode 100644 index 0000000..837d4fa --- /dev/null +++ b/Friendly/Sources/ScannerQr/QrScannerDelegate.swift @@ -0,0 +1,21 @@ +// +// QrScannerDelegate.swift +// Friendly +// +// Created by Konstantin on 04.02.2026. +// + +import SwiftUI +import AVKit + +class QrScannerDelegate: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { + @Published var scanndeCode: String? + + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + if let metaObject = metadataObjects.first { + guard let readableObject = metaObject as? AVMetadataMachineReadableCodeObject else { return } + guard let stringValue = readableObject.stringValue else { return } + scanndeCode = stringValue + } + } +} diff --git a/Friendly/Sources/ScannerQr/ScanView.swift b/Friendly/Sources/ScannerQr/ScanView.swift new file mode 100644 index 0000000..460b9fd --- /dev/null +++ b/Friendly/Sources/ScannerQr/ScanView.swift @@ -0,0 +1,105 @@ +// +// ScanView.swift +// Friendly +// +// Created by Konstantin on 05.02.2026. +// + +import SwiftUI + +struct ScanToUseAppView: View { + @StateObject private var viewModel: ScanToUseAppViewModel + + init(onSuccess: @escaping (Bool) -> Void) { + _viewModel = StateObject(wrappedValue: ScanToUseAppViewModel(onSuccess: onSuccess)) + } + + var body: some View { + NavigationView { + ZStack { + content + + if viewModel.state == .loading { + loadingOverlay + } + } + .navigationBarTitleDisplayMode(.inline) + .alert( + "scan_enter_error_alert_title", + isPresented: $viewModel.isErrorAlertPresented + ) { + Button("scan_enter_error_alert_button_close") { viewModel.retry() } + Button("scan_enter_error_alert_button_cancel", role: .cancel) { } + } message: { + Text(viewModel.errorMessage ?? "error_base_message") + } + .sheet(isPresented: $viewModel.isScannerPresented) { + QRScannerCameraView { code in + guard let code, !code.isEmpty else { + viewModel.closeScanner() + return + } + viewModel.handleScanned(code: code) + } + } + } + } + + private var content: some View { + VStack(spacing: 20) { + Spacer() + + Image(systemName: "qrcode.viewfinder") + .font(.system(size: 56)) + .padding(.bottom, 8) + + Text("scan_enter_info_title") + .font(.title3) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + + + Text("scan_enter_info_subtitle") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + + Spacer() + + Button { + viewModel.openScanner() + } label: { + Text("scan_enter_open_scanner") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.glassProminent) + .padding(.horizontal) + + Spacer(minLength: 24) + } + } + + private var loadingOverlay: some View { + ZStack { + Color.black.opacity(0.25).ignoresSafeArea() + + VStack(spacing: 12) { + ProgressView() + Text("scan_enter_scanning_title") + .font(.headline) + Text("scan_enter_scanning_subtitle") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(20) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding(.horizontal, 32) + } + .transition(.opacity) + } +} diff --git a/Friendly/Sources/ScannerQr/ScanViewModel.swift b/Friendly/Sources/ScannerQr/ScanViewModel.swift new file mode 100644 index 0000000..4b8d1bb --- /dev/null +++ b/Friendly/Sources/ScannerQr/ScanViewModel.swift @@ -0,0 +1,70 @@ +// +// ScanViewModel.swift +// Friendly +// +// Created by Konstantin on 05.02.2026. +// + +import Foundation +import SwiftUI + +@MainActor +final class ScanToUseAppViewModel: ObservableObject { + + enum State: Equatable { + case idle + case loading + } + + @Published var state: State = .idle + @Published var isScannerPresented = false + @Published var isErrorAlertPresented = false + @Published var errorMessage: String? + + private let onSuccess: (Bool) -> Void + + private var lastScannedCode: String? + + init(onSuccess: @escaping (Bool) -> Void) { + self.onSuccess = onSuccess + } + + func openScanner() { + guard state != .loading else { return } + isScannerPresented = true + } + + func closeScanner() { + isScannerPresented = false + } + + func handleScanned(code: String) { + isScannerPresented = false + lastScannedCode = code + validate(code: code) + } + + func retry() { + guard let code = lastScannedCode else { return } + validate(code: code) + } + + private func validate(code: String) { + guard state != .loading else { return } + state = .loading + errorMessage = nil + isErrorAlertPresented = false + + Task { + do { + // Проверить код, записать его на бек + state = .idle + onSuccess(true) + } catch { + state = .idle + errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + isErrorAlertPresented = true + } + } + } +} From 1e435b73899f3c3206933e52edeb6335a525466e Mon Sep 17 00:00:00 2001 From: ckost9n Date: Fri, 6 Feb 2026 18:02:16 +0300 Subject: [PATCH 2/3] Added QR scanner blocking screen after authorization and gallery scanning --- Friendly/Sources/ContentView.swift | 4 + Friendly/Sources/ContentViewModel.swift | 13 ++- Friendly/Sources/Deeplink.swift | 80 ++++++++++++++ Friendly/Sources/Localizable.xcstrings | 101 ++++++++++++++++++ Friendly/Sources/NetworkView.swift | 7 +- Friendly/Sources/ScannerQr/ScanView.swift | 63 ++++++++++- .../Sources/ScannerQr/ScanViewModel.swift | 76 +++++++++---- Friendly/Sources/Storage.swift | 60 +++++++++++ 8 files changed, 377 insertions(+), 27 deletions(-) diff --git a/Friendly/Sources/ContentView.swift b/Friendly/Sources/ContentView.swift index f651655..09aea30 100644 --- a/Friendly/Sources/ContentView.swift +++ b/Friendly/Sources/ContentView.swift @@ -15,6 +15,10 @@ struct ContentView: View { routeToSignUp: viewModel.routeToSignUp, addFriend: $viewModel.addFriend, ) + case .qrAddFriend: + ScanToUseAppView(isBlocked: true) { friend in + viewModel.onAddFriendWithQr(id: friend.id, token: friend.token) + } } } .animation(.easeInOut(duration: 0.3), value: viewModel.destination) diff --git a/Friendly/Sources/ContentViewModel.swift b/Friendly/Sources/ContentViewModel.swift index 5d92224..88e92ab 100644 --- a/Friendly/Sources/ContentViewModel.swift +++ b/Friendly/Sources/ContentViewModel.swift @@ -10,7 +10,11 @@ class ContentViewModel { func appear() { do { if try storage.hasAuthorization() { - destination = .main + if try storage.getHasFriend() { + destination = .main + } else { + destination = .qrAddFriend + } } else { destination = .signUp } @@ -21,7 +25,13 @@ class ContentViewModel { } func onSignUp() { + destination = .qrAddFriend + } + + func onAddFriendWithQr(id: UserId, token: FriendToken) { + try? storage.addFriend() destination = .main + addFriend = AddFriendCommand(id: id, token: token) } func routeToSignUp() { @@ -36,5 +46,6 @@ class ContentViewModel { case empty case signUp case main + case qrAddFriend } } diff --git a/Friendly/Sources/Deeplink.swift b/Friendly/Sources/Deeplink.swift index d9fde7d..8d9dd8e 100644 --- a/Friendly/Sources/Deeplink.swift +++ b/Friendly/Sources/Deeplink.swift @@ -18,3 +18,83 @@ enum Deeplink { return nil } } + +extension Deeplink { + // friendly://add// + private static let friendlyRegex: Regex<(Substring, Substring, Substring)> = + try! Regex(#"^friendly://add/([^/]+)/(.+)$"#) + + // add// + private static let referenceRegex: Regex<(Substring, Substring, Substring)> = + try! Regex(#"^add/([^/]+)/(.+)$"#) + + static func parseOf(url: URL) -> Deeplink? { + if let deeplink = parseFriendly(url: url) { return deeplink } + if let deeplink = parseReferenceURL(url: url) { return deeplink } + return nil + } +} + +private extension Deeplink { + static func parseFriendly(url: URL) -> Deeplink? { + guard url.scheme?.lowercased() == "friendly" else { return nil } + + let string = url.absoluteString + guard let match = try? friendlyRegex.wholeMatch(in: string) else { return nil } + + let rawId = String(match.output.1) + let rawToken = String(match.output.2) + return makeAddFriend(rawId: rawId, rawToken: rawToken) + } + + static func parseReferenceURL(url: URL) -> Deeplink? { + if let reference = url.queryValue("reference"), + let deeplink = parseReference(reference) { + return deeplink + } + + if let fragment = url.fragment, + let reference = extractReference(from: fragment), + let deeplink = parseReference(reference) { + return deeplink + } + + return nil + } + + static func parseReference(_ reference: String) -> Deeplink? { + let decoded = reference.removingPercentEncoding ?? reference + guard let match = try? referenceRegex.wholeMatch(in: decoded) else { return nil } + + let rawId = String(match.output.1) + let rawToken = String(match.output.2) + return makeAddFriend(rawId: rawId, rawToken: rawToken) + } + + static func extractReference(from fragment: String) -> String? { + let decoded = fragment.removingPercentEncoding ?? fragment + guard let range = decoded.range(of: "reference=") else { return nil } + + let tail = decoded[range.upperBound...] + if let amp = tail.firstIndex(of: "&") { + return String(tail[.. Deeplink? { + guard let idInt64 = Int64(rawId) else { return nil } + let id = UserId(idInt64) + guard let token = try? FriendToken(rawToken) else { return nil } + return .addFriend(id: id, token: token) + } +} + +private extension URL { + func queryValue(_ name: String) -> String? { + URLComponents(url: self, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == name })? + .value + } +} diff --git a/Friendly/Sources/Localizable.xcstrings b/Friendly/Sources/Localizable.xcstrings index 216ca02..7017839 100644 --- a/Friendly/Sources/Localizable.xcstrings +++ b/Friendly/Sources/Localizable.xcstrings @@ -34,6 +34,23 @@ } } }, + "error_base_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to verify the data. Please try again later." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось проверить данные. Попробуйте ещё раз позже." + } + } + } + }, "error_base_subtite" : { "localizations" : { "en" : { @@ -491,6 +508,57 @@ } } }, + "scan_enter_error_alert_button_cancel" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + } + } + }, + "scan_enter_error_alert_button_close" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try again" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторить" + } + } + } + }, + "scan_enter_error_alert_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "QR verification failed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка проверки QR" + } + } + } + }, "scan_enter_info_subtitle" : { "localizations" : { "en" : { @@ -523,6 +591,22 @@ } } }, + "scan_enter_open_photo_scanner" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scan from Photos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сканировать из фото" + } + } + } + }, "scan_enter_open_scanner" : { "localizations" : { "en" : { @@ -539,6 +623,23 @@ } } }, + "scan_enter_photo_qr_not_found" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn’t find a QR code in the selected image" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось найти QR-код на выбранном изображении" + } + } + } + }, "scan_enter_scanning_subtitle" : { "localizations" : { "en" : { diff --git a/Friendly/Sources/NetworkView.swift b/Friendly/Sources/NetworkView.swift index 2552e24..8b5576c 100644 --- a/Friendly/Sources/NetworkView.swift +++ b/Friendly/Sources/NetworkView.swift @@ -51,9 +51,10 @@ struct NetworkView: View { .sheet( isPresented: $viewModel.shouldFindQRCode, ) { - ScanToUseAppView( - onSuccess: { _ in viewModel.shouldFindQRCode = false }, - ) + ScanToUseAppView(isBlocked: false) { friend in + viewModel.shouldFindQRCode = false + viewModel.command(addFriend: friend) + } } .onAppear { viewModel.appear() } .navigationDestination( diff --git a/Friendly/Sources/ScannerQr/ScanView.swift b/Friendly/Sources/ScannerQr/ScanView.swift index 460b9fd..9fdf90c 100644 --- a/Friendly/Sources/ScannerQr/ScanView.swift +++ b/Friendly/Sources/ScannerQr/ScanView.swift @@ -6,11 +6,21 @@ // import SwiftUI +import PhotosUI struct ScanToUseAppView: View { @StateObject private var viewModel: ScanToUseAppViewModel + @State private var pickedPhotoItem: PhotosPickerItem? = nil + private var isBlocked: Bool + + @Environment(\.openURL) private var openURL + @Environment(\.dismiss) private var dismiss - init(onSuccess: @escaping (Bool) -> Void) { + init( + isBlocked: Bool, + onSuccess: @escaping (AddFriendCommand) -> Void + ) { + self.isBlocked = isBlocked _viewModel = StateObject(wrappedValue: ScanToUseAppViewModel(onSuccess: onSuccess)) } @@ -24,14 +34,24 @@ struct ScanToUseAppView: View { } } .navigationBarTitleDisplayMode(.inline) + .toolbar { + if !isBlocked { + toolbarContent + } + } .alert( "scan_enter_error_alert_title", isPresented: $viewModel.isErrorAlertPresented ) { - Button("scan_enter_error_alert_button_close") { viewModel.retry() } Button("scan_enter_error_alert_button_cancel", role: .cancel) { } } message: { - Text(viewModel.errorMessage ?? "error_base_message") + Text( + String( + localized: LocalizedStringResource( + stringLiteral: viewModel.errorMessage ?? "error_base_message" + ) + ) + ) } .sheet(isPresented: $viewModel.isScannerPresented) { QRScannerCameraView { code in @@ -42,6 +62,18 @@ struct ScanToUseAppView: View { viewModel.handleScanned(code: code) } } + .onChange(of: pickedPhotoItem) { _, newItem in + guard let newItem else { return } + Task { + defer { pickedPhotoItem = nil } + if let data = try? await newItem.loadTransferable(type: Data.self) { + viewModel.handlePickedImageData(data) + } else { + viewModel.errorMessage = "scan_enter_photo_invalid_image" + viewModel.isErrorAlertPresented = true + } + } + } } } @@ -79,6 +111,19 @@ struct ScanToUseAppView: View { .buttonStyle(.glassProminent) .padding(.horizontal) + PhotosPicker( + selection: $pickedPhotoItem, + matching: .images, + photoLibrary: .shared() + ) { + Text("scan_enter_open_photo_scanner") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .buttonStyle(.glass) + .padding(.horizontal) + Spacer(minLength: 24) } } @@ -102,4 +147,16 @@ struct ScanToUseAppView: View { } .transition(.opacity) } + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .principal) { + Text("scanner_qrcode_navigation_title") + } + ToolbarItem(placement: .primaryAction) { + Button(action: { dismiss() }) { + Image(systemName: "xmark") + } + } + } } diff --git a/Friendly/Sources/ScannerQr/ScanViewModel.swift b/Friendly/Sources/ScannerQr/ScanViewModel.swift index 4b8d1bb..4cd15d9 100644 --- a/Friendly/Sources/ScannerQr/ScanViewModel.swift +++ b/Friendly/Sources/ScannerQr/ScanViewModel.swift @@ -20,12 +20,10 @@ final class ScanToUseAppViewModel: ObservableObject { @Published var isScannerPresented = false @Published var isErrorAlertPresented = false @Published var errorMessage: String? - - private let onSuccess: (Bool) -> Void - - private var lastScannedCode: String? - - init(onSuccess: @escaping (Bool) -> Void) { + + private let onSuccess: (AddFriendCommand) -> Void + + init(onSuccess: @escaping (AddFriendCommand) -> Void) { self.onSuccess = onSuccess } @@ -40,31 +38,69 @@ final class ScanToUseAppViewModel: ObservableObject { func handleScanned(code: String) { isScannerPresented = false - lastScannedCode = code - validate(code: code) + validate(from: code) } + + private func validate(from code: String) { + state = .idle + errorMessage = nil + isErrorAlertPresented = false + + do { + let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines) + + guard let url = URL(string: trimmed) else { + throw ScanEnterError.invalidURL + } - func retry() { - guard let code = lastScannedCode else { return } - validate(code: code) + let deeplink = Deeplink.parseOf(url: url) + switch deeplink { + case .addFriend(let id, let token): + onSuccess(AddFriendCommand(id: id, token: token)) + case nil: + throw ScanEnterError.invalidURL + } + + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + isErrorAlertPresented = true + } } +} - private func validate(code: String) { - guard state != .loading else { return } +extension ScanToUseAppViewModel { + func handlePickedImageData(_ data: Data) { state = .loading errorMessage = nil isErrorAlertPresented = false Task { - do { - // Проверить код, записать его на бек - state = .idle - onSuccess(true) - } catch { - state = .idle - errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + guard let image = UIImage(data: data), + let cgImage = image.cgImage else { + errorMessage = "scan_enter_photo_invalid_image" + isErrorAlertPresented = true + return + } + + let codes = cgImage.detectQRCodeStrings() + guard let code = codes.first, !code.isEmpty else { + errorMessage = "scan_enter_photo_qr_not_found" isErrorAlertPresented = true + return } + + handleScanned(code: code) + } + } +} + +enum ScanEnterError: LocalizedError { + case invalidURL + + var errorDescription: String? { + switch self { + case .invalidURL: + return String(localized: "scan_enter_invalid_url") } } } diff --git a/Friendly/Sources/Storage.swift b/Friendly/Sources/Storage.swift index 1dd2565..1390e78 100644 --- a/Friendly/Sources/Storage.swift +++ b/Friendly/Sources/Storage.swift @@ -60,6 +60,7 @@ class Storage { try? clearToken() try? clearId() try? clearAccessHash() + try? clearHasFriend() } private func clearToken() throws(Error) { @@ -95,6 +96,65 @@ class Storage { } } + func getHasFriend() throws(Error) -> Bool { + do { + return try loadHasFriend() + } catch { + throw .ioError + } + } + + func addFriend() throws(Error) { + try saveHasFriend(true) + } + + private func saveHasFriend(_ value: Bool) throws(Error) { + let byte: UInt8 = value ? 1 : 0 + let data = Data([byte]) + + _ = SecItemDelete([ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: "authorization.hasFriend", + ] as CFDictionary) + + let query = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: "authorization.hasFriend", + kSecValueData: data, + ] as CFDictionary + let status = SecItemAdd(query, nil) + guard status == errSecSuccess else { throw .ioError } + } + + private func clearHasFriend() throws(Error) { + let status = SecItemDelete([ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: "authorization.hasFriend", + ] as CFDictionary) + + guard status == errSecSuccess || status == errSecItemNotFound else { throw .ioError } + } + + private func loadHasFriend() throws(Error) -> Bool { + var data: AnyObject? + let query = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: "authorization.hasFriend", + kSecReturnData: true, + ] as CFDictionary + + let status = SecItemCopyMatching(query, &data) + + if status == errSecItemNotFound { + return false + } + guard status == errSecSuccess, let data = data as? Data else { + throw .ioError + } + + return data.first == 1 + } + func hasAuthorization() throws(Error) -> Bool { let query = [ kSecClass: kSecClassGenericPassword, From ac4d04d571138eb50283cef837ea5cdb0afd0c07 Mon Sep 17 00:00:00 2001 From: ckost9n Date: Fri, 6 Feb 2026 18:52:59 +0300 Subject: [PATCH 3/3] Update logic blocking screen --- Friendly/Sources/ContentView.swift | 4 +-- Friendly/Sources/ContentViewModel.swift | 3 +- Friendly/Sources/NetworkView.swift | 5 +-- Friendly/Sources/ScannerQr/ScanView.swift | 36 ++++++------------- .../Sources/ScannerQr/ScanViewModel.swift | 36 ++++++++++++++++--- 5 files changed, 45 insertions(+), 39 deletions(-) diff --git a/Friendly/Sources/ContentView.swift b/Friendly/Sources/ContentView.swift index 09aea30..78b52db 100644 --- a/Friendly/Sources/ContentView.swift +++ b/Friendly/Sources/ContentView.swift @@ -16,9 +16,7 @@ struct ContentView: View { addFriend: $viewModel.addFriend, ) case .qrAddFriend: - ScanToUseAppView(isBlocked: true) { friend in - viewModel.onAddFriendWithQr(id: friend.id, token: friend.token) - } + ScanToUseAppView(isBlocked: true) { viewModel.onAddFriendWithQr() } } } .animation(.easeInOut(duration: 0.3), value: viewModel.destination) diff --git a/Friendly/Sources/ContentViewModel.swift b/Friendly/Sources/ContentViewModel.swift index 88e92ab..a423ba3 100644 --- a/Friendly/Sources/ContentViewModel.swift +++ b/Friendly/Sources/ContentViewModel.swift @@ -28,10 +28,9 @@ class ContentViewModel { destination = .qrAddFriend } - func onAddFriendWithQr(id: UserId, token: FriendToken) { + func onAddFriendWithQr() { try? storage.addFriend() destination = .main - addFriend = AddFriendCommand(id: id, token: token) } func routeToSignUp() { diff --git a/Friendly/Sources/NetworkView.swift b/Friendly/Sources/NetworkView.swift index 8b5576c..d5867ff 100644 --- a/Friendly/Sources/NetworkView.swift +++ b/Friendly/Sources/NetworkView.swift @@ -51,10 +51,7 @@ struct NetworkView: View { .sheet( isPresented: $viewModel.shouldFindQRCode, ) { - ScanToUseAppView(isBlocked: false) { friend in - viewModel.shouldFindQRCode = false - viewModel.command(addFriend: friend) - } + ScanToUseAppView(isBlocked: false) { viewModel.shouldFindQRCode = false } } .onAppear { viewModel.appear() } .navigationDestination( diff --git a/Friendly/Sources/ScannerQr/ScanView.swift b/Friendly/Sources/ScannerQr/ScanView.swift index 9fdf90c..50e3fa5 100644 --- a/Friendly/Sources/ScannerQr/ScanView.swift +++ b/Friendly/Sources/ScannerQr/ScanView.swift @@ -18,7 +18,7 @@ struct ScanToUseAppView: View { init( isBlocked: Bool, - onSuccess: @escaping (AddFriendCommand) -> Void + onSuccess: @escaping () -> Void ) { self.isBlocked = isBlocked _viewModel = StateObject(wrappedValue: ScanToUseAppViewModel(onSuccess: onSuccess)) @@ -27,10 +27,9 @@ struct ScanToUseAppView: View { var body: some View { NavigationView { ZStack { - content - - if viewModel.state == .loading { - loadingOverlay + switch viewModel.state { + case .idle: content + case .loading: LoadingView() } } .navigationBarTitleDisplayMode(.inline) @@ -128,26 +127,6 @@ struct ScanToUseAppView: View { } } - private var loadingOverlay: some View { - ZStack { - Color.black.opacity(0.25).ignoresSafeArea() - - VStack(spacing: 12) { - ProgressView() - Text("scan_enter_scanning_title") - .font(.headline) - Text("scan_enter_scanning_subtitle") - .font(.subheadline) - .foregroundStyle(.secondary) - } - .padding(20) - .background(.ultraThinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .padding(.horizontal, 32) - } - .transition(.opacity) - } - @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { ToolbarItem(placement: .principal) { @@ -160,3 +139,10 @@ struct ScanToUseAppView: View { } } } + +private struct LoadingView: View { + var body: some View { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Friendly/Sources/ScannerQr/ScanViewModel.swift b/Friendly/Sources/ScannerQr/ScanViewModel.swift index 4cd15d9..b1a0c8e 100644 --- a/Friendly/Sources/ScannerQr/ScanViewModel.swift +++ b/Friendly/Sources/ScannerQr/ScanViewModel.swift @@ -10,20 +10,22 @@ import SwiftUI @MainActor final class ScanToUseAppViewModel: ObservableObject { - enum State: Equatable { case idle case loading } + + private let storage: Storage = .shared + private let networkClient: NetworkClient = .meetacy @Published var state: State = .idle @Published var isScannerPresented = false @Published var isErrorAlertPresented = false @Published var errorMessage: String? - private let onSuccess: (AddFriendCommand) -> Void + private let onSuccess: () -> Void - init(onSuccess: @escaping (AddFriendCommand) -> Void) { + init(onSuccess: @escaping () -> Void) { self.onSuccess = onSuccess } @@ -40,6 +42,30 @@ final class ScanToUseAppViewModel: ObservableObject { isScannerPresented = false validate(from: code) } + + private func add(friend: AddFriendCommand) { + state = .loading + errorMessage = nil + isErrorAlertPresented = false + + let id = friend.id + let token = friend.token + Task { + guard let authorization = try? storage.loadAuthorization() else { + isErrorAlertPresented = true + return + } + guard let _ = try? await networkClient.friendsAdd( + authorization: authorization, + token: token, + id: id, + ) else { + isErrorAlertPresented = true + return + } + onSuccess() + } + } private func validate(from code: String) { state = .idle @@ -55,8 +81,8 @@ final class ScanToUseAppViewModel: ObservableObject { let deeplink = Deeplink.parseOf(url: url) switch deeplink { - case .addFriend(let id, let token): - onSuccess(AddFriendCommand(id: id, token: token)) + case let .addFriend(id, token): + add(friend: AddFriendCommand(id: id, token: token)) case nil: throw ScanEnterError.invalidURL }