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>
319 lines
11 KiB
Swift
319 lines
11 KiB
Swift
//
|
||
// 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()
|
||
}
|
||
}
|