// // OrderCodeScannerViewController.swift // suixinkan // import AVFoundation import SnapKit import UIKit /// 订单扫码错误实体,描述相机不可用、权限拒绝和元数据异常。 enum OrderScannerError: LocalizedError { case cameraUnavailable case permissionDenied case invalidMetadata var errorDescription: String? { switch self { case .cameraUnavailable: "当前设备不可用相机,无法扫码。" case .permissionDenied: "未开启相机权限,请在系统设置中允许后重试。" case .invalidMetadata: "未识别到有效条码,请重试。" } } } /// 订单扫码页,包装 AVFoundation 扫码控制器并提供关闭与结果回调。 final class OrderCodeScannerViewController: UIViewController { var onScanResult: ((Result) -> Void)? private let scannerController = OrderScannerCaptureViewController() override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black title = "扫码核销" navigationItem.leftBarButtonItem = UIBarButtonItem(title: "关闭", style: .plain, target: self, action: #selector(closeTapped)) #if targetEnvironment(simulator) showUnavailablePlaceholder() #else if AppUITestLaunchState.isRunningUITests || AVCaptureDevice.default(for: .video) == nil { showUnavailablePlaceholder() } else { embedScanner() } #endif } private func embedScanner() { scannerController.onScanResult = { [weak self] result in self?.onScanResult?(result) } addChild(scannerController) view.addSubview(scannerController.view) scannerController.view.snp.makeConstraints { make in make.edges.equalToSuperview() } scannerController.didMove(toParent: self) let hintLabel = UILabel() hintLabel.text = "请将二维码/条码放入取景框" hintLabel.font = .systemFont(ofSize: AppMetrics.FontSize.footnote, weight: .medium) hintLabel.textColor = .white hintLabel.backgroundColor = UIColor.black.withAlphaComponent(0.55) hintLabel.layer.cornerRadius = 16 hintLabel.clipsToBounds = true hintLabel.textAlignment = .center view.addSubview(hintLabel) hintLabel.snp.makeConstraints { make in make.centerX.equalToSuperview() make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppMetrics.Spacing.xxLarge) make.height.equalTo(32) make.width.greaterThanOrEqualTo(220) } } private func showUnavailablePlaceholder() { let stack = UIStackView() stack.axis = .vertical stack.spacing = AppMetrics.Spacing.small stack.alignment = .center let icon = UIImageView(image: UIImage(systemName: "camera.viewfinder")) icon.tintColor = .white icon.contentMode = .scaleAspectFit let titleLabel = UILabel() titleLabel.text = "当前环境不可用相机" titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold) titleLabel.textColor = .white let messageLabel = UILabel() messageLabel.text = "请在真机上使用扫码核销" messageLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) messageLabel.textColor = UIColor.white.withAlphaComponent(0.72) stack.addArrangedSubview(icon) stack.addArrangedSubview(titleLabel) stack.addArrangedSubview(messageLabel) view.addSubview(stack) icon.snp.makeConstraints { make in make.width.height.equalTo(48) } stack.snp.makeConstraints { make in make.center.equalToSuperview() } } @objc private func closeTapped() { dismiss(animated: true) } } /// AVFoundation 扫码控制器,管理相机权限、预览层、识别类型和手电筒。 final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { var onScanResult: ((Result) -> Void)? private let session = AVCaptureSession() private var previewLayer: AVCaptureVideoPreviewLayer? private var videoDevice: AVCaptureDevice? private var hasFinishedScan = false private let scanFrameView: UIView = { let view = UIView() view.layer.borderColor = UIColor.systemBlue.cgColor view.layer.borderWidth = 2 view.layer.cornerRadius = 12 view.backgroundColor = .clear return view }() private lazy var torchButton: UIButton = { let button = Self.makeScannerButton(title: "手电筒") button.addTarget(self, action: #selector(toggleTorch), for: .touchUpInside) return button }() private lazy var retryButton: UIButton = { let button = Self.makeScannerButton(title: "重新扫描") button.addTarget(self, action: #selector(restartScan), for: .touchUpInside) return button }() override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black setupControls() checkPermissionAndStart() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() previewLayer?.frame = view.layer.bounds } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) updateTorch(enabled: false) stopSession() } private func checkPermissionAndStart() { switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: startSession() case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in DispatchQueue.main.async { guard let self else { return } if granted { self.startSession() } else { self.onScanResult?(.failure(OrderScannerError.permissionDenied)) } } } case .denied, .restricted: onScanResult?(.failure(OrderScannerError.permissionDenied)) @unknown default: onScanResult?(.failure(OrderScannerError.permissionDenied)) } } private func startSession() { hasFinishedScan = false guard !session.isRunning else { return } do { try configureSession() } catch { onScanResult?(.failure(error)) return } DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.startRunning() } } private func stopSession() { guard session.isRunning else { return } session.stopRunning() } private static func makeScannerButton(title: String) -> UIButton { var config = UIButton.Configuration.filled() config.title = title config.baseForegroundColor = .white config.baseBackgroundColor = UIColor.black.withAlphaComponent(0.42) config.cornerStyle = .capsule return UIButton(configuration: config) } private func configureSession() throws { if previewLayer != nil { return } guard let videoDevice = AVCaptureDevice.default(for: .video) else { throw OrderScannerError.cameraUnavailable } self.videoDevice = videoDevice let videoInput = try AVCaptureDeviceInput(device: videoDevice) guard session.canAddInput(videoInput) else { throw OrderScannerError.cameraUnavailable } session.addInput(videoInput) let metadataOutput = AVCaptureMetadataOutput() guard session.canAddOutput(metadataOutput) else { throw OrderScannerError.invalidMetadata } session.addOutput(metadataOutput) metadataOutput.setMetadataObjectsDelegate(self, queue: .main) let desiredTypes: [AVMetadataObject.ObjectType] = [.qr, .ean8, .ean13, .pdf417, .code39, .code93, .code128, .dataMatrix, .aztec, .itf14, .upce] let supportedTypes = desiredTypes.filter { metadataOutput.availableMetadataObjectTypes.contains($0) } guard !supportedTypes.isEmpty else { throw OrderScannerError.invalidMetadata } metadataOutput.metadataObjectTypes = supportedTypes let preview = AVCaptureVideoPreviewLayer(session: session) preview.videoGravity = .resizeAspectFill preview.frame = view.layer.bounds view.layer.insertSublayer(preview, at: 0) previewLayer = preview torchButton.isEnabled = videoDevice.hasTorch } func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { guard !hasFinishedScan else { return } guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, let code = metadataObject.stringValue, !code.isEmpty else { return } hasFinishedScan = true stopSession() UINotificationFeedbackGenerator().notificationOccurred(.success) onScanResult?(.success(code)) } private func setupControls() { view.addSubview(scanFrameView) view.addSubview(torchButton) view.addSubview(retryButton) scanFrameView.snp.makeConstraints { make in make.centerX.equalToSuperview() make.centerY.equalToSuperview().offset(-40) make.width.height.equalTo(240) } torchButton.snp.makeConstraints { make in make.leading.equalTo(view.safeAreaLayoutGuide).inset(16) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(18) } retryButton.snp.makeConstraints { make in make.trailing.equalTo(view.safeAreaLayoutGuide).inset(16) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(18) } } @objc private func toggleTorch() { updateTorch(enabled: !(videoDevice?.isTorchActive ?? false)) } private func updateTorch(enabled: Bool) { guard let device = videoDevice, device.hasTorch else { return } do { try device.lockForConfiguration() if enabled { try device.setTorchModeOn(level: AVCaptureDevice.maxAvailableTorchLevel) } else { device.torchMode = .off } device.unlockForConfiguration() torchButton.configuration?.title = enabled ? "关闭手电" : "手电筒" } catch { torchButton.configuration?.title = "手电筒不可用" } } @objc private func restartScan() { hasFinishedScan = false updateTorch(enabled: false) startSession() } }