Files
suixinkan_ios_uikit/suixinkan_ios/Features/Orders/ViewControllers/OrderCodeScannerViewController.swift
汉秋 d99a5b1bf8 Advance UIKit rewrite with AMap integration and core UI modules.
Integrate高德 SDK with simulator-safe build flags, add map views for operating area and punch points, refactor main tabs and key feature screens to UIKit with Diffable lists, and document Swift concurrency defaults in AGENTS.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 15:16:12 +08:00

319 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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<String, Error>) -> Void)?
private let scannerController = OrderScannerCaptureViewController()
/// UI
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
}
/// embed
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)
}
}
/// UnavailablePlaceholder
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()
}
}
/// close
@objc private func closeTapped() {
dismiss(animated: true)
}
}
/// AVFoundation
final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
var onScanResult: ((Result<String, Error>) -> 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
}()
/// UI
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
setupControls()
checkPermissionAndStart()
}
/// frame UI
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
previewLayer?.frame = view.layer.bounds
}
///
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
updateTorch(enabled: false)
stopSession()
}
/// check
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))
}
}
/// Session
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()
}
}
/// Session
private func stopSession() {
guard session.isRunning else { return }
session.stopRunning()
}
/// ScannerButton
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)
}
/// Session
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
}
/// metadataOutput
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))
}
/// Controls UI
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)
}
}
/// toggleTorch
@objc private func toggleTorch() {
updateTorch(enabled: !(videoDevice?.isTorchActive ?? false))
}
/// Torch
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 = "手电筒不可用"
}
}
/// restart
@objc private func restartScan() {
hasFinishedScan = false
updateTorch(enabled: false)
startSession()
}
}