Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,301 @@
//
// 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()
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<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
}()
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()
}
}