同步订单详情与扫码核销流程
This commit is contained in:
330
suixinkan/Features/Orders/Views/OrderCodeScannerView.swift
Normal file
330
suixinkan/Features/Orders/Views/OrderCodeScannerView.swift
Normal file
@ -0,0 +1,330 @@
|
||||
//
|
||||
// OrderCodeScannerView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
/// 订单扫码错误实体,描述相机不可用、权限拒绝和元数据异常。
|
||||
enum OrderScannerError: LocalizedError {
|
||||
case cameraUnavailable
|
||||
case permissionDenied
|
||||
case invalidMetadata
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .cameraUnavailable:
|
||||
return "当前设备不可用相机,无法扫码。"
|
||||
case .permissionDenied:
|
||||
return "未开启相机权限,请在系统设置中允许后重试。"
|
||||
case .invalidMetadata:
|
||||
return "未识别到有效条码,请重试。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI 扫码页包装,提供标题、关闭按钮和底部扫码提示。
|
||||
struct OrderScannerPage: View {
|
||||
let onClose: () -> Void
|
||||
let onSuccess: (String) -> Void
|
||||
let onFailure: (Error) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack(alignment: .bottom) {
|
||||
OrderCodeScannerView { result in
|
||||
switch result {
|
||||
case .success(let raw):
|
||||
onSuccess(raw)
|
||||
case .failure(let error):
|
||||
onFailure(error)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
|
||||
Label("请将二维码/条码放入取景框", systemImage: "qrcode.viewfinder")
|
||||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||
.padding(.vertical, AppMetrics.Spacing.xSmall)
|
||||
.background(.black.opacity(0.55), in: Capsule())
|
||||
.padding(.bottom, AppMetrics.Spacing.xxLarge)
|
||||
}
|
||||
.navigationTitle("扫码核销")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("关闭") {
|
||||
onClose()
|
||||
}
|
||||
.accessibilityIdentifier("scanner.close")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AVFoundation 扫码桥接视图,负责把相机识别结果回传给 SwiftUI。
|
||||
struct OrderCodeScannerView: UIViewControllerRepresentable {
|
||||
let onScanResult: (Result<String, Error>) -> Void
|
||||
|
||||
/// 创建底层扫码控制器。
|
||||
func makeUIViewController(context: Context) -> OrderScannerViewController {
|
||||
let controller = OrderScannerViewController()
|
||||
controller.onScanResult = onScanResult
|
||||
return controller
|
||||
}
|
||||
|
||||
/// 扫码控制器没有 SwiftUI 驱动的可变配置。
|
||||
func updateUIViewController(_ uiViewController: OrderScannerViewController, context: Context) {}
|
||||
}
|
||||
|
||||
/// AVFoundation 扫码控制器,管理相机权限、预览层、识别类型和手电筒。
|
||||
final class OrderScannerViewController: 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
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
private let guideLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.text = "将二维码/条形码放入框内自动识别"
|
||||
label.textColor = .white
|
||||
label.font = .systemFont(ofSize: 13, weight: .medium)
|
||||
label.textAlignment = .center
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var torchButton: UIButton = {
|
||||
let button = Self.makeScannerButton(title: "手电筒", backgroundColor: UIColor.black.withAlphaComponent(0.42))
|
||||
button.addTarget(self, action: #selector(toggleTorch), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
private lazy var retryButton: UIButton = {
|
||||
let button = Self.makeScannerButton(title: "重新扫描", backgroundColor: UIColor.systemBlue.withAlphaComponent(0.85))
|
||||
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, backgroundColor: UIColor) -> UIButton {
|
||||
var attributedTitle = AttributedString(title)
|
||||
attributedTitle.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||
|
||||
var configuration = UIButton.Configuration.filled()
|
||||
configuration.attributedTitle = attributedTitle
|
||||
configuration.baseForegroundColor = .white
|
||||
configuration.baseBackgroundColor = backgroundColor
|
||||
configuration.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)
|
||||
configuration.cornerStyle = .capsule
|
||||
|
||||
let button = UIButton(type: .system)
|
||||
button.configuration = configuration
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
return button
|
||||
}
|
||||
|
||||
/// 配置相机输入、元数据输出和预览层。
|
||||
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: DispatchQueue.main)
|
||||
metadataOutput.metadataObjectTypes = [
|
||||
.qr,
|
||||
.ean8,
|
||||
.ean13,
|
||||
.pdf417,
|
||||
.code39,
|
||||
.code93,
|
||||
.code128,
|
||||
.dataMatrix,
|
||||
.aztec,
|
||||
.itf14,
|
||||
.upce
|
||||
]
|
||||
|
||||
let preview = AVCaptureVideoPreviewLayer(session: session)
|
||||
preview.videoGravity = .resizeAspectFill
|
||||
preview.frame = view.layer.bounds
|
||||
view.layer.insertSublayer(preview, at: 0)
|
||||
previewLayer = preview
|
||||
updateTorchButtonState()
|
||||
}
|
||||
|
||||
/// 处理相机识别到的二维码或条形码内容。
|
||||
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(guideLabel)
|
||||
view.addSubview(torchButton)
|
||||
view.addSubview(retryButton)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
scanFrameView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
scanFrameView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -40),
|
||||
scanFrameView.widthAnchor.constraint(equalToConstant: 240),
|
||||
scanFrameView.heightAnchor.constraint(equalToConstant: 240),
|
||||
guideLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
|
||||
guideLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
|
||||
guideLabel.topAnchor.constraint(equalTo: scanFrameView.bottomAnchor, constant: 14),
|
||||
torchButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
|
||||
torchButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18),
|
||||
retryButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
|
||||
retryButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18)
|
||||
])
|
||||
}
|
||||
|
||||
/// 根据设备能力更新手电筒按钮状态。
|
||||
private func updateTorchButtonState() {
|
||||
let hasTorch = videoDevice?.hasTorch == true
|
||||
torchButton.isEnabled = hasTorch
|
||||
torchButton.alpha = hasTorch ? 1 : 0.45
|
||||
}
|
||||
|
||||
/// 切换手电筒开关。
|
||||
@objc
|
||||
private func toggleTorch() {
|
||||
let next = !(videoDevice?.isTorchActive ?? false)
|
||||
updateTorch(enabled: next)
|
||||
}
|
||||
|
||||
/// 设置手电筒状态。
|
||||
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.setTitle(enabled ? "关闭手电" : "手电筒", for: .normal)
|
||||
} catch {
|
||||
torchButton.setTitle("手电筒不可用", for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置扫描状态并重新启动识别。
|
||||
@objc
|
||||
private func restartScan() {
|
||||
hasFinishedScan = false
|
||||
updateTorch(enabled: false)
|
||||
startSession()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user