|
|
import SwiftUI
|
|
|
import AVFoundation
|
|
|
import AudioToolbox
|
|
|
import Combine
|
|
|
|
|
|
// MARK: - 主扫描视图
|
|
|
struct ScannerView: View {
|
|
|
@StateObject private var scannerViewModel = ScannerViewModel()
|
|
|
@State private var showPreviewPause = false
|
|
|
@State private var selectedScanningStyle: ScanningLineStyle = .modern
|
|
|
@State private var screenOrientation = UIDevice.current.orientation
|
|
|
@State private var previewLayer: AVCaptureVideoPreviewLayer?
|
|
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
|
|
var body: some View {
|
|
|
ZStack {
|
|
|
// 相机权限检查
|
|
|
if scannerViewModel.cameraAuthorizationStatus == .authorized {
|
|
|
// 相机预览层
|
|
|
CameraPreviewView(session: scannerViewModel.captureSession, previewLayer: $previewLayer)
|
|
|
.ignoresSafeArea()
|
|
|
|
|
|
// 扫描界面覆盖层
|
|
|
ScanningOverlayView(
|
|
|
showPreviewPause: showPreviewPause,
|
|
|
selectedStyle: $selectedScanningStyle,
|
|
|
detectedCodesCount: scannerViewModel.detectedCodes.count,
|
|
|
onClose: { dismiss() }
|
|
|
)
|
|
|
|
|
|
// 条码位置标记覆盖层
|
|
|
if showPreviewPause && !scannerViewModel.detectedCodes.isEmpty {
|
|
|
CodePositionOverlay(
|
|
|
detectedCodes: scannerViewModel.detectedCodes,
|
|
|
previewLayer: previewLayer,
|
|
|
onCodeSelected: handleCodeSelection,
|
|
|
onRescan: resetToScanning
|
|
|
)
|
|
|
}
|
|
|
|
|
|
// 测试按钮(调试用)
|
|
|
if showPreviewPause && scannerViewModel.detectedCodes.count == 1 {
|
|
|
TestAutoSelectButton(
|
|
|
detectedCode: scannerViewModel.detectedCodes[0],
|
|
|
onSelect: handleCodeSelection
|
|
|
)
|
|
|
}
|
|
|
} else {
|
|
|
// 权限未授权时的UI
|
|
|
CameraPermissionView(
|
|
|
authorizationStatus: scannerViewModel.cameraAuthorizationStatus,
|
|
|
onRequestPermission: {
|
|
|
scannerViewModel.refreshCameraPermission()
|
|
|
},
|
|
|
onOpenSettings: {
|
|
|
scannerViewModel.openSettings()
|
|
|
}
|
|
|
)
|
|
|
}
|
|
|
}
|
|
|
.onAppear {
|
|
|
// 只有在相机权限已授权时才启动扫描
|
|
|
if scannerViewModel.cameraAuthorizationStatus == .authorized {
|
|
|
scannerViewModel.startScanning()
|
|
|
}
|
|
|
}
|
|
|
.onDisappear {
|
|
|
scannerViewModel.stopScanning()
|
|
|
}
|
|
|
.alert("scan_error_title".localized, isPresented: $scannerViewModel.showAlert) {
|
|
|
Button("OK") { }
|
|
|
} message: {
|
|
|
Text("scan_error_message".localized)
|
|
|
}
|
|
|
.onReceive(scannerViewModel.$detectedCodes) { codes in
|
|
|
handleDetectedCodes(codes)
|
|
|
}
|
|
|
.onReceive(scannerViewModel.$cameraAuthorizationStatus) { status in
|
|
|
if status == .authorized {
|
|
|
logInfo("🎯 相机权限已授权,启动扫描", className: "ScannerView")
|
|
|
scannerViewModel.startScanning()
|
|
|
}
|
|
|
}
|
|
|
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
|
|
|
handleOrientationChange()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 私有方法
|
|
|
|
|
|
private func handleDetectedCodes(_ codes: [DetectedCode]) {
|
|
|
guard !codes.isEmpty else { return }
|
|
|
|
|
|
logInfo("检测到条码数量: \(codes.count)", className: "ScannerView")
|
|
|
|
|
|
if codes.count == 1 {
|
|
|
logInfo("单个条码,准备自动选择", className: "ScannerView")
|
|
|
pauseForPreview()
|
|
|
autoSelectSingleCode(code: codes[0])
|
|
|
} else {
|
|
|
logInfo("多个条码,等待用户选择", className: "ScannerView")
|
|
|
pauseForPreview()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private func handleOrientationChange() {
|
|
|
screenOrientation = UIDevice.current.orientation
|
|
|
logInfo("Screen orientation changed to: \(screenOrientation.rawValue)", className: "ScannerView")
|
|
|
}
|
|
|
|
|
|
private func handleCodeSelection(_ selectedCode: DetectedCode) {
|
|
|
logInfo("🎯 ScannerView 收到条码选择回调", className: "ScannerView")
|
|
|
logInfo(" 选择的条码ID: \(selectedCode.id)", className: "ScannerView")
|
|
|
logInfo(" 选择的条码类型: \(selectedCode.type)", className: "ScannerView")
|
|
|
logInfo(" 选择的条码内容: \(selectedCode.content)", className: "ScannerView")
|
|
|
logInfo(" 选择的条码位置: \(selectedCode.bounds)", className: "ScannerView")
|
|
|
|
|
|
let formattedResult = "类型: \(selectedCode.type)\n内容: \(selectedCode.content)"
|
|
|
logInfo(" 格式化结果: \(formattedResult)", className: "ScannerView")
|
|
|
|
|
|
NotificationCenter.default.post(name: .scannerDidScanCode, object: formattedResult)
|
|
|
dismiss()
|
|
|
}
|
|
|
|
|
|
private func pauseForPreview() {
|
|
|
showPreviewPause = true
|
|
|
}
|
|
|
|
|
|
private func resetToScanning() {
|
|
|
logInfo("🔄 ScannerView 开始重置到扫描状态", className: "ScannerView")
|
|
|
|
|
|
// 重置UI状态
|
|
|
showPreviewPause = false
|
|
|
|
|
|
// 重置扫描状态并重新开始
|
|
|
scannerViewModel.resetDetection()
|
|
|
scannerViewModel.restartScanning()
|
|
|
|
|
|
// 延迟检查会话状态
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|
|
logInfo("🔍 检查扫描会话状态", className: "ScannerView")
|
|
|
self.scannerViewModel.checkSessionStatus()
|
|
|
}
|
|
|
|
|
|
logInfo("✅ ScannerView 已重置到扫描状态", className: "ScannerView")
|
|
|
}
|
|
|
|
|
|
private func autoSelectSingleCode(code: DetectedCode) {
|
|
|
logInfo("开始自动选择定时器,条码类型: \(code.type)", className: "ScannerView")
|
|
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|
|
guard self.showPreviewPause && self.scannerViewModel.detectedCodes.count == 1 else {
|
|
|
logInfo("条件不满足,取消自动选择", className: "ScannerView")
|
|
|
return
|
|
|
}
|
|
|
|
|
|
logInfo("条件满足,执行自动选择", className: "ScannerView")
|
|
|
self.handleCodeSelection(code)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 扫描界面覆盖层
|
|
|
struct ScanningOverlayView: View {
|
|
|
let showPreviewPause: Bool
|
|
|
@Binding var selectedStyle: ScanningLineStyle
|
|
|
let detectedCodesCount: Int
|
|
|
let onClose: () -> Void
|
|
|
|
|
|
var body: some View {
|
|
|
VStack {
|
|
|
Spacer()
|
|
|
|
|
|
// 扫描线组件
|
|
|
if !showPreviewPause {
|
|
|
ScanningLineView(style: selectedStyle)
|
|
|
}
|
|
|
|
|
|
// 提示文本
|
|
|
ScanningInstructionView(
|
|
|
showPreviewPause: showPreviewPause,
|
|
|
detectedCodesCount: detectedCodesCount
|
|
|
)
|
|
|
|
|
|
Spacer()
|
|
|
|
|
|
// 底部按钮区域
|
|
|
ScanningBottomButtonsView(
|
|
|
showPreviewPause: showPreviewPause,
|
|
|
selectedStyle: $selectedStyle,
|
|
|
onClose: onClose
|
|
|
)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 扫描指令视图
|
|
|
struct ScanningInstructionView: View {
|
|
|
let showPreviewPause: Bool
|
|
|
let detectedCodesCount: Int
|
|
|
|
|
|
var body: some View {
|
|
|
if showPreviewPause {
|
|
|
VStack(spacing: 8) {
|
|
|
Text("detected_codes".localized)
|
|
|
.foregroundColor(.white)
|
|
|
.font(.headline)
|
|
|
|
|
|
if detectedCodesCount == 1 {
|
|
|
Text("auto_result_1s".localized)
|
|
|
.foregroundColor(.green)
|
|
|
.font(.subheadline)
|
|
|
} else {
|
|
|
Text("select_code_instruction".localized)
|
|
|
.foregroundColor(.white.opacity(0.8))
|
|
|
.font(.subheadline)
|
|
|
}
|
|
|
}
|
|
|
.padding(.top, 20)
|
|
|
} else {
|
|
|
Text("scan_instruction".localized)
|
|
|
.foregroundColor(.white)
|
|
|
.font(.headline)
|
|
|
.padding(.top, 20)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 扫描底部按钮视图
|
|
|
struct ScanningBottomButtonsView: View {
|
|
|
let showPreviewPause: Bool
|
|
|
@Binding var selectedStyle: ScanningLineStyle
|
|
|
let onClose: () -> Void
|
|
|
|
|
|
var body: some View {
|
|
|
VStack(spacing: 15) {
|
|
|
// 扫描线样式选择器
|
|
|
if !showPreviewPause {
|
|
|
ScanningStyleSelectorView(selectedStyle: $selectedStyle)
|
|
|
}
|
|
|
|
|
|
// 关闭按钮 - 只在非预览选择状态时显示
|
|
|
if !showPreviewPause {
|
|
|
Button("close_button".localized) {
|
|
|
onClose()
|
|
|
}
|
|
|
.foregroundColor(.white)
|
|
|
.padding()
|
|
|
.background(Color.black.opacity(0.6))
|
|
|
.cornerRadius(10)
|
|
|
}
|
|
|
}
|
|
|
.padding(.bottom, 50)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 扫描线样式选择器
|
|
|
struct ScanningStyleSelectorView: View {
|
|
|
@Binding var selectedStyle: ScanningLineStyle
|
|
|
|
|
|
var body: some View {
|
|
|
HStack(spacing: 10) {
|
|
|
ForEach(ScanningLineStyle.allCases, id: \.self) { style in
|
|
|
Button(style.localizedName) {
|
|
|
selectedStyle = style
|
|
|
}
|
|
|
.foregroundColor(.white)
|
|
|
.padding(.horizontal, 8)
|
|
|
.padding(.vertical, 4)
|
|
|
.background(selectedStyle == style ? Color.green : Color.gray.opacity(0.6))
|
|
|
.cornerRadius(8)
|
|
|
.font(.caption)
|
|
|
}
|
|
|
}
|
|
|
.padding(.bottom, 10)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 测试自动选择按钮
|
|
|
struct TestAutoSelectButton: View {
|
|
|
let detectedCode: DetectedCode
|
|
|
let onSelect: (DetectedCode) -> Void
|
|
|
|
|
|
var body: some View {
|
|
|
VStack {
|
|
|
HStack {
|
|
|
Spacer()
|
|
|
Button("test_auto_select".localized) {
|
|
|
onSelect(detectedCode)
|
|
|
}
|
|
|
.foregroundColor(.white)
|
|
|
.padding(8)
|
|
|
.background(Color.red)
|
|
|
.cornerRadius(8)
|
|
|
.padding(.trailing, 20)
|
|
|
}
|
|
|
Spacer()
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 相机预览视图
|
|
|
struct CameraPreviewView: UIViewRepresentable {
|
|
|
let session: AVCaptureSession
|
|
|
@Binding var previewLayer: AVCaptureVideoPreviewLayer?
|
|
|
|
|
|
func makeUIView(context: Context) -> UIView {
|
|
|
let view = UIView()
|
|
|
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
|
|
previewLayer.frame = view.bounds
|
|
|
previewLayer.videoGravity = .resizeAspectFill
|
|
|
view.layer.addSublayer(previewLayer)
|
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
self.previewLayer = previewLayer
|
|
|
}
|
|
|
|
|
|
return view
|
|
|
}
|
|
|
|
|
|
func updateUIView(_ uiView: UIView, context: Context) {
|
|
|
if let previewLayer = uiView.layer.sublayers?.first as? AVCaptureVideoPreviewLayer {
|
|
|
previewLayer.frame = uiView.bounds
|
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
self.previewLayer = previewLayer
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 扫描器视图模型
|
|
|
class ScannerViewModel: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate {
|
|
|
@Published var detectedCodes: [DetectedCode] = []
|
|
|
@Published var showAlert = false
|
|
|
@Published var cameraAuthorizationStatus: AVAuthorizationStatus = .notDetermined
|
|
|
@Published var showPermissionAlert = false
|
|
|
|
|
|
var captureSession: AVCaptureSession!
|
|
|
private var metadataOutput: AVCaptureMetadataOutput?
|
|
|
|
|
|
override init() {
|
|
|
super.init()
|
|
|
checkCameraPermission()
|
|
|
}
|
|
|
|
|
|
// MARK: - 相机权限管理
|
|
|
|
|
|
private func checkCameraPermission() {
|
|
|
logInfo("🔍 检查相机权限状态", className: "ScannerViewModel")
|
|
|
|
|
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
|
|
case .authorized:
|
|
|
logInfo("✅ 相机权限已授权", className: "ScannerViewModel")
|
|
|
cameraAuthorizationStatus = .authorized
|
|
|
setupCaptureSession()
|
|
|
|
|
|
case .notDetermined:
|
|
|
logInfo("❓ 相机权限未确定,请求权限", className: "ScannerViewModel")
|
|
|
requestCameraPermission()
|
|
|
|
|
|
case .denied, .restricted:
|
|
|
logWarning("❌ 相机权限被拒绝或受限", className: "ScannerViewModel")
|
|
|
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
|
|
showPermissionAlert = true
|
|
|
|
|
|
@unknown default:
|
|
|
logWarning("❓ 未知的相机权限状态", className: "ScannerViewModel")
|
|
|
cameraAuthorizationStatus = .notDetermined
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private func requestCameraPermission() {
|
|
|
logInfo("🔐 请求相机权限", className: "ScannerViewModel")
|
|
|
|
|
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
|
|
DispatchQueue.main.async {
|
|
|
if granted {
|
|
|
logInfo("✅ 相机权限请求成功", className: "ScannerViewModel")
|
|
|
self?.cameraAuthorizationStatus = .authorized
|
|
|
self?.setupCaptureSession()
|
|
|
} else {
|
|
|
logWarning("❌ 相机权限请求被拒绝", className: "ScannerViewModel")
|
|
|
self?.cameraAuthorizationStatus = .denied
|
|
|
self?.showPermissionAlert = true
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func openSettings() {
|
|
|
logInfo("⚙️ 打开系统设置", className: "ScannerViewModel")
|
|
|
|
|
|
if let settingsUrl = URL(string: UIApplication.openSettingsURLString) {
|
|
|
UIApplication.shared.open(settingsUrl) { success in
|
|
|
if success {
|
|
|
logInfo("✅ 成功打开系统设置", className: "ScannerViewModel")
|
|
|
} else {
|
|
|
logWarning("⚠️ 打开系统设置失败", className: "ScannerViewModel")
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func refreshCameraPermission() {
|
|
|
logInfo("🔍 重新检查相机权限状态", className: "ScannerViewModel")
|
|
|
checkCameraPermission()
|
|
|
}
|
|
|
|
|
|
// MARK: - 相机设置
|
|
|
|
|
|
private func setupCaptureSession() {
|
|
|
captureSession = AVCaptureSession()
|
|
|
|
|
|
guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else {
|
|
|
showAlert = true
|
|
|
return
|
|
|
}
|
|
|
|
|
|
let videoInput: AVCaptureDeviceInput
|
|
|
|
|
|
do {
|
|
|
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
|
|
|
} catch {
|
|
|
showAlert = true
|
|
|
return
|
|
|
}
|
|
|
|
|
|
if captureSession.canAddInput(videoInput) {
|
|
|
captureSession.addInput(videoInput)
|
|
|
} else {
|
|
|
showAlert = true
|
|
|
return
|
|
|
}
|
|
|
|
|
|
metadataOutput = AVCaptureMetadataOutput()
|
|
|
|
|
|
if let metadataOutput = metadataOutput,
|
|
|
captureSession.canAddOutput(metadataOutput) {
|
|
|
captureSession.addOutput(metadataOutput)
|
|
|
metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
|
|
metadataOutput.metadataObjectTypes = [.qr, .ean8, .ean13, .code128, .code39, .upce, .pdf417, .aztec]
|
|
|
} else {
|
|
|
showAlert = true
|
|
|
return
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 扫描控制
|
|
|
|
|
|
func startScanning() {
|
|
|
logInfo("🔄 开始扫描", className: "ScannerViewModel")
|
|
|
|
|
|
// 检查会话是否已经在运行
|
|
|
if captureSession?.isRunning == true {
|
|
|
logInfo("ℹ️ 扫描会话已经在运行", className: "ScannerViewModel")
|
|
|
return
|
|
|
}
|
|
|
|
|
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
|
guard let self = self else { return }
|
|
|
|
|
|
self.captureSession?.startRunning()
|
|
|
|
|
|
// 检查启动状态
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
|
if self.captureSession?.isRunning == true {
|
|
|
logInfo("✅ 扫描会话启动成功", className: "ScannerViewModel")
|
|
|
} else {
|
|
|
logWarning("⚠️ 扫描会话启动失败", className: "ScannerViewModel")
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func stopScanning() {
|
|
|
logInfo("🔄 停止扫描", className: "ScannerViewModel")
|
|
|
|
|
|
// 检查会话是否在运行
|
|
|
if captureSession?.isRunning == true {
|
|
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
|
self?.captureSession?.stopRunning()
|
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
logInfo("✅ 扫描会话已停止", className: "ScannerViewModel")
|
|
|
}
|
|
|
}
|
|
|
} else {
|
|
|
logInfo("ℹ️ 扫描会话已经停止", className: "ScannerViewModel")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func resetDetection() {
|
|
|
DispatchQueue.main.async {
|
|
|
logInfo("🔄 重置检测状态,清空 detectedCodes", className: "ScannerViewModel")
|
|
|
self.detectedCodes = []
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func isSessionRunning() -> Bool {
|
|
|
return captureSession?.isRunning == true
|
|
|
}
|
|
|
|
|
|
func checkSessionStatus() {
|
|
|
let isRunning = captureSession?.isRunning == true
|
|
|
logInfo("📊 扫描会话状态检查: \(isRunning ? "运行中" : "已停止")", className: "ScannerViewModel")
|
|
|
|
|
|
if !isRunning {
|
|
|
logWarning("⚠️ 扫描会话未运行,尝试重新启动", className: "ScannerViewModel")
|
|
|
startScanning()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func restartScanning() {
|
|
|
logInfo("🔄 重新开始扫描", className: "ScannerViewModel")
|
|
|
|
|
|
// 确保在主线程执行UI相关操作
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
|
guard let self = self else { return }
|
|
|
|
|
|
// 先停止当前会话
|
|
|
if self.captureSession?.isRunning == true {
|
|
|
logInfo("🔄 停止当前运行的扫描会话", className: "ScannerViewModel")
|
|
|
self.captureSession?.stopRunning()
|
|
|
}
|
|
|
|
|
|
// 重置检测状态
|
|
|
self.detectedCodes = []
|
|
|
|
|
|
// 延迟后重新启动会话
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
|
|
|
|
logInfo("🔄 准备重新启动扫描会话", className: "ScannerViewModel")
|
|
|
|
|
|
// 在后台线程启动会话
|
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
|
self.captureSession?.startRunning()
|
|
|
|
|
|
// 检查会话状态
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
|
if self.captureSession?.isRunning == true {
|
|
|
logInfo("✅ 扫描会话已成功重新启动", className: "ScannerViewModel")
|
|
|
} else {
|
|
|
logWarning("⚠️ 扫描会话启动失败,尝试重新启动", className: "ScannerViewModel")
|
|
|
// 如果启动失败,再次尝试
|
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
|
self.captureSession?.startRunning()
|
|
|
DispatchQueue.main.async {
|
|
|
if self.captureSession?.isRunning == true {
|
|
|
logInfo("✅ 扫描会话第二次尝试启动成功", className: "ScannerViewModel")
|
|
|
} else {
|
|
|
logError("❌ 扫描会话启动失败", className: "ScannerViewModel")
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - AVCaptureMetadataOutputObjectsDelegate
|
|
|
|
|
|
func metadataOutput(_ output: AVCaptureMetadataOutput,
|
|
|
didOutput metadataObjects: [AVMetadataObject],
|
|
|
from connection: AVCaptureConnection) {
|
|
|
|
|
|
logInfo("metadataOutput 被调用,检测到 \(metadataObjects.count) 个对象", className: "ScannerViewModel")
|
|
|
|
|
|
// 震动反馈
|
|
|
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
|
|
|
|
|
|
// 停止扫描
|
|
|
stopScanning()
|
|
|
|
|
|
// 处理所有检测到的条码
|
|
|
var codes: [DetectedCode] = []
|
|
|
|
|
|
for metadataObject in metadataObjects {
|
|
|
if let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject,
|
|
|
let stringValue = readableObject.stringValue {
|
|
|
|
|
|
let codeType = readableObject.type.rawValue
|
|
|
let bounds = readableObject.bounds
|
|
|
|
|
|
let detectedCode = DetectedCode(
|
|
|
type: codeType,
|
|
|
content: stringValue,
|
|
|
bounds: bounds
|
|
|
)
|
|
|
|
|
|
codes.append(detectedCode)
|
|
|
logInfo("创建 DetectedCode: 类型=\(codeType), 内容=\(stringValue)", className: "ScannerViewModel")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
logInfo("准备更新 detectedCodes,数量: \(codes.count)", className: "ScannerViewModel")
|
|
|
|
|
|
// 更新检测到的条码列表
|
|
|
DispatchQueue.main.async {
|
|
|
logInfo("在主线程更新 detectedCodes", className: "ScannerViewModel")
|
|
|
self.detectedCodes = codes
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 条码位置标记覆盖层
|
|
|
struct CodePositionOverlay: View {
|
|
|
let detectedCodes: [DetectedCode]
|
|
|
let previewLayer: AVCaptureVideoPreviewLayer?
|
|
|
let onCodeSelected: (DetectedCode) -> Void
|
|
|
let onRescan: () -> Void
|
|
|
|
|
|
var body: some View {
|
|
|
GeometryReader { geometry in
|
|
|
ZStack {
|
|
|
ForEach(detectedCodes) { code in
|
|
|
CodePositionMarker(
|
|
|
code: code,
|
|
|
screenSize: geometry.size,
|
|
|
previewLayer: previewLayer,
|
|
|
onCodeSelected: onCodeSelected
|
|
|
)
|
|
|
}
|
|
|
|
|
|
// 调试信息:显示触摸区域边界
|
|
|
#if DEBUG
|
|
|
ForEach(detectedCodes) { code in
|
|
|
let position = calculateDebugPosition(code: code, screenSize: geometry.size)
|
|
|
Rectangle()
|
|
|
.stroke(Color.red, lineWidth: 1)
|
|
|
.frame(width: 80, height: 80)
|
|
|
.position(x: position.x, y: position.y)
|
|
|
.opacity(0.3)
|
|
|
}
|
|
|
#endif
|
|
|
|
|
|
// 重新扫描按钮 - 放在右上角
|
|
|
VStack {
|
|
|
HStack {
|
|
|
Spacer()
|
|
|
Button(action: {
|
|
|
logInfo("🔄 用户点击重新扫描按钮", className: "CodePositionOverlay")
|
|
|
|
|
|
// 添加触觉反馈
|
|
|
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
|
|
|
impactFeedback.impactOccurred()
|
|
|
|
|
|
onRescan()
|
|
|
}) {
|
|
|
HStack(spacing: 8) {
|
|
|
Image(systemName: "arrow.clockwise")
|
|
|
.font(.system(size: 18, weight: .semibold))
|
|
|
.rotationEffect(.degrees(0))
|
|
|
.animation(.easeInOut(duration: 0.3), value: true)
|
|
|
|
|
|
Text("rescan_button".localized)
|
|
|
.font(.system(size: 15, weight: .semibold))
|
|
|
}
|
|
|
.foregroundColor(.white)
|
|
|
.padding(.horizontal, 20)
|
|
|
.padding(.vertical, 10)
|
|
|
.background(
|
|
|
RoundedRectangle(cornerRadius: 25)
|
|
|
.fill(Color.blue.opacity(0.9))
|
|
|
.shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2)
|
|
|
)
|
|
|
}
|
|
|
.buttonStyle(RescanButtonStyle())
|
|
|
.padding(.trailing, 25)
|
|
|
.padding(.top, 25)
|
|
|
|
|
|
// 调试按钮:检查会话状态
|
|
|
#if DEBUG
|
|
|
Button(action: {
|
|
|
logInfo("🔍 调试:检查扫描会话状态", className: "CodePositionOverlay")
|
|
|
// 这里可以添加会话状态检查逻辑
|
|
|
}) {
|
|
|
Image(systemName: "info.circle")
|
|
|
.font(.system(size: 16, weight: .medium))
|
|
|
.foregroundColor(.yellow)
|
|
|
.padding(8)
|
|
|
.background(Color.black.opacity(0.6))
|
|
|
.clipShape(Circle())
|
|
|
}
|
|
|
.padding(.trailing, 10)
|
|
|
.padding(.top, 25)
|
|
|
#endif
|
|
|
}
|
|
|
Spacer()
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
.allowsHitTesting(true)
|
|
|
.contentShape(Rectangle()) // 确保整个区域都可以接收触摸事件
|
|
|
.zIndex(1000) // 确保在最上层,不被其他视图遮挡
|
|
|
}
|
|
|
|
|
|
// 调试用的位置计算方法
|
|
|
private func calculateDebugPosition(code: DetectedCode, screenSize: CGSize) -> CGPoint {
|
|
|
guard let previewLayer = previewLayer else {
|
|
|
return CGPoint(x: screenSize.width / 2, y: screenSize.height / 2)
|
|
|
}
|
|
|
|
|
|
let metadataObject = code.bounds
|
|
|
let convertedPoint = previewLayer.layerPointConverted(fromCaptureDevicePoint: CGPoint(
|
|
|
x: metadataObject.midX,
|
|
|
y: metadataObject.midY
|
|
|
))
|
|
|
|
|
|
let clampedX = max(40, min(screenSize.width - 40, convertedPoint.x))
|
|
|
let clampedY = max(40, min(screenSize.height - 40, convertedPoint.y))
|
|
|
|
|
|
return CGPoint(x: clampedX, y: clampedY)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 单个条码位置标记
|
|
|
struct CodePositionMarker: View {
|
|
|
let code: DetectedCode
|
|
|
let screenSize: CGSize
|
|
|
let previewLayer: AVCaptureVideoPreviewLayer?
|
|
|
let onCodeSelected: (DetectedCode) -> Void
|
|
|
|
|
|
var body: some View {
|
|
|
GeometryReader { geometry in
|
|
|
let position = calculatePosition(screenSize: geometry.size)
|
|
|
|
|
|
ZStack {
|
|
|
// 触摸区域背景(透明,但可以接收触摸事件)
|
|
|
Circle()
|
|
|
.fill(Color.clear)
|
|
|
.frame(width: 80, height: 80)
|
|
|
.contentShape(Circle())
|
|
|
.onTapGesture {
|
|
|
logDebug("🎯 CodePositionMarker 被点击!", className: "CodePositionMarker")
|
|
|
logDebug(" 条码ID: \(code.id)", className: "CodePositionMarker")
|
|
|
logDebug(" 条码类型: \(code.type)", className: "CodePositionMarker")
|
|
|
logDebug(" 条码内容: \(code.content)", className: "CodePositionMarker")
|
|
|
logDebug(" 点击位置: x=\(position.x), y=\(position.y)", className: "CodePositionMarker")
|
|
|
|
|
|
// 添加触觉反馈
|
|
|
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
|
|
|
impactFeedback.impactOccurred()
|
|
|
|
|
|
onCodeSelected(code)
|
|
|
}
|
|
|
|
|
|
// 外圈
|
|
|
Circle()
|
|
|
.stroke(Color.green, lineWidth: 3)
|
|
|
.frame(width: 40, height: 40)
|
|
|
|
|
|
// 内圈
|
|
|
Circle()
|
|
|
.fill(Color.green.opacity(0.3))
|
|
|
.frame(width: 20, height: 20)
|
|
|
|
|
|
// 中心点
|
|
|
Circle()
|
|
|
.fill(Color.green)
|
|
|
.frame(width: 6, height: 6)
|
|
|
}
|
|
|
.position(x: position.x, y: position.y)
|
|
|
.zIndex(1001) // 确保触摸区域在最上层
|
|
|
.onAppear {
|
|
|
logDebug("CodePositionMarker appeared at: x=\(position.x), y=\(position.y)", className: "CodePositionMarker")
|
|
|
logDebug("Screen size: \(geometry.size)", className: "CodePositionMarker")
|
|
|
logDebug("Code bounds: \(code.bounds)", className: "CodePositionMarker")
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private func calculatePosition(screenSize: CGSize) -> CGPoint {
|
|
|
guard let previewLayer = previewLayer else {
|
|
|
logWarning("No preview layer available, using screen center", className: "CodePositionMarker")
|
|
|
return CGPoint(x: screenSize.width / 2, y: screenSize.height / 2)
|
|
|
}
|
|
|
|
|
|
guard previewLayer.session?.isRunning == true else {
|
|
|
logWarning("Preview layer session not running, using screen center", className: "CodePositionMarker")
|
|
|
return CGPoint(x: screenSize.width / 2, y: screenSize.height / 2)
|
|
|
}
|
|
|
|
|
|
let metadataObject = code.bounds
|
|
|
let convertedPoint = previewLayer.layerPointConverted(fromCaptureDevicePoint: CGPoint(
|
|
|
x: metadataObject.midX,
|
|
|
y: metadataObject.midY
|
|
|
))
|
|
|
|
|
|
guard convertedPoint.x.isFinite && convertedPoint.y.isFinite else {
|
|
|
logWarning("Invalid converted point: \(convertedPoint), using screen center", className: "CodePositionMarker")
|
|
|
return CGPoint(x: screenSize.width / 2, y: screenSize.height / 2)
|
|
|
}
|
|
|
|
|
|
let clampedX = max(20, min(screenSize.width - 20, convertedPoint.x))
|
|
|
let clampedY = max(20, min(screenSize.height - 20, convertedPoint.y))
|
|
|
|
|
|
logDebug("AVFoundation bounds: \(code.bounds)", className: "CodePositionMarker")
|
|
|
logDebug("Converted point: \(convertedPoint)", className: "CodePositionMarker")
|
|
|
logDebug("Screen size: \(screenSize)", className: "CodePositionMarker")
|
|
|
logDebug("Clamped: x=\(clampedX), y=\(clampedY)", className: "CodePositionMarker")
|
|
|
|
|
|
return CGPoint(x: clampedX, y: clampedY)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 重新扫描按钮样式
|
|
|
struct RescanButtonStyle: ButtonStyle {
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
|
configuration.label
|
|
|
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
|
|
|
.opacity(configuration.isPressed ? 0.8 : 1.0)
|
|
|
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 扫描线动画修饰符
|
|
|
struct ScanningLineModifier: ViewModifier {
|
|
|
@State private var isAnimating = false
|
|
|
|
|
|
func body(content: Content) -> some View {
|
|
|
content
|
|
|
.offset(y: isAnimating ? 150 : -150)
|
|
|
.onAppear {
|
|
|
withAnimation(
|
|
|
Animation.linear(duration: 2)
|
|
|
.repeatForever(autoreverses: false)
|
|
|
) {
|
|
|
isAnimating = true
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 脉冲动画修饰符
|
|
|
struct PulseAnimationModifier: ViewModifier {
|
|
|
@State private var isPulsing = false
|
|
|
|
|
|
func body(content: Content) -> some View {
|
|
|
content
|
|
|
.scaleEffect(isPulsing ? 1.5 : 1.0)
|
|
|
.opacity(isPulsing ? 0.0 : 0.8)
|
|
|
.onAppear {
|
|
|
withAnimation(
|
|
|
Animation.easeInOut(duration: 1.5)
|
|
|
.repeatForever(autoreverses: false)
|
|
|
) {
|
|
|
isPulsing = true
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 扫描线样式枚举
|
|
|
enum ScanningLineStyle: String, CaseIterable {
|
|
|
case modern = "style_modern"
|
|
|
case classic = "style_classic"
|
|
|
case neon = "style_neon"
|
|
|
case minimal = "style_minimal"
|
|
|
case retro = "style_retro"
|
|
|
|
|
|
var localizedName: String {
|
|
|
switch self {
|
|
|
case .modern: return "style_modern".localized
|
|
|
case .classic: return "style_classic".localized
|
|
|
case .neon: return "style_neon".localized
|
|
|
case .minimal: return "style_minimal".localized
|
|
|
case .retro: return "style_retro".localized
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 扫描线视图
|
|
|
struct ScanningLineView: View {
|
|
|
let style: ScanningLineStyle
|
|
|
|
|
|
var body: some View {
|
|
|
Group {
|
|
|
switch style {
|
|
|
case .modern:
|
|
|
ModernScanningLine()
|
|
|
case .classic:
|
|
|
ClassicScanningLine()
|
|
|
case .neon:
|
|
|
NeonScanningLine()
|
|
|
case .minimal:
|
|
|
MinimalScanningLine()
|
|
|
case .retro:
|
|
|
RetroScanningLine()
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 现代扫描线
|
|
|
struct ModernScanningLine: View {
|
|
|
var body: some View {
|
|
|
Rectangle()
|
|
|
.fill(
|
|
|
LinearGradient(
|
|
|
colors: [.blue, .cyan, .blue],
|
|
|
startPoint: .leading,
|
|
|
endPoint: .trailing
|
|
|
)
|
|
|
)
|
|
|
.frame(width: 200, height: 3)
|
|
|
.shadow(color: .blue, radius: 5, x: 0, y: 0)
|
|
|
.modifier(ScanningLineModifier())
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 经典扫描线
|
|
|
struct ClassicScanningLine: View {
|
|
|
var body: some View {
|
|
|
Rectangle()
|
|
|
.fill(Color.green)
|
|
|
.frame(width: 150, height: 2)
|
|
|
.modifier(ScanningLineModifier())
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 霓虹扫描线
|
|
|
struct NeonScanningLine: View {
|
|
|
var body: some View {
|
|
|
Rectangle()
|
|
|
.fill(Color.purple)
|
|
|
.frame(width: 180, height: 4)
|
|
|
.shadow(color: .purple, radius: 8, x: 0, y: 0)
|
|
|
.modifier(ScanningLineModifier())
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 极简扫描线
|
|
|
struct MinimalScanningLine: View {
|
|
|
var body: some View {
|
|
|
Rectangle()
|
|
|
.fill(Color.white)
|
|
|
.frame(width: 100, height: 1)
|
|
|
.modifier(ScanningLineModifier())
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 复古扫描线
|
|
|
struct RetroScanningLine: View {
|
|
|
var body: some View {
|
|
|
HStack(spacing: 2) {
|
|
|
ForEach(0..<5, id: \.self) { _ in
|
|
|
Rectangle()
|
|
|
.fill(Color.orange)
|
|
|
.frame(width: 2, height: 20)
|
|
|
}
|
|
|
}
|
|
|
.modifier(ScanningLineModifier())
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 检测到的条码数据结构
|
|
|
struct DetectedCode: Identifiable {
|
|
|
let id = UUID()
|
|
|
let type: String
|
|
|
let content: String
|
|
|
let bounds: CGRect
|
|
|
}
|
|
|
|
|
|
// MARK: - 通知名称扩展
|
|
|
extension Notification.Name {
|
|
|
static let scannerDidScanCode = Notification.Name("scannerDidScanCode")
|
|
|
}
|
|
|
|
|
|
// MARK: - 相机权限视图
|
|
|
struct CameraPermissionView: View {
|
|
|
let authorizationStatus: AVAuthorizationStatus
|
|
|
let onRequestPermission: () -> Void
|
|
|
let onOpenSettings: () -> Void
|
|
|
|
|
|
var body: some View {
|
|
|
VStack(spacing: 30) {
|
|
|
Spacer()
|
|
|
|
|
|
// 相机图标
|
|
|
Image(systemName: "camera.fill")
|
|
|
.font(.system(size: 80))
|
|
|
.foregroundColor(.gray)
|
|
|
|
|
|
// 标题
|
|
|
Text("camera_permission_title".localized)
|
|
|
.font(.largeTitle)
|
|
|
.fontWeight(.bold)
|
|
|
.multilineTextAlignment(.center)
|
|
|
|
|
|
// 描述文本
|
|
|
Text(getDescriptionText())
|
|
|
.font(.body)
|
|
|
.multilineTextAlignment(.center)
|
|
|
.foregroundColor(.secondary)
|
|
|
.padding(.horizontal, 40)
|
|
|
|
|
|
// 操作按钮
|
|
|
VStack(spacing: 15) {
|
|
|
if authorizationStatus == .notDetermined {
|
|
|
Button(action: onRequestPermission) {
|
|
|
HStack {
|
|
|
Image(systemName: "camera.badge.ellipsis")
|
|
|
Text("request_camera_permission".localized)
|
|
|
}
|
|
|
.font(.headline)
|
|
|
.foregroundColor(.white)
|
|
|
.frame(maxWidth: .infinity)
|
|
|
.padding()
|
|
|
.background(Color.blue)
|
|
|
.cornerRadius(12)
|
|
|
}
|
|
|
} else if authorizationStatus == .denied || authorizationStatus == .restricted {
|
|
|
Button(action: onOpenSettings) {
|
|
|
HStack {
|
|
|
Image(systemName: "gear")
|
|
|
Text("open_settings".localized)
|
|
|
}
|
|
|
.font(.headline)
|
|
|
.foregroundColor(.white)
|
|
|
.frame(maxWidth: .infinity)
|
|
|
.padding()
|
|
|
.background(Color.orange)
|
|
|
.cornerRadius(12)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
.padding(.horizontal, 40)
|
|
|
|
|
|
Spacer()
|
|
|
}
|
|
|
.background(Color(.systemBackground))
|
|
|
}
|
|
|
|
|
|
private func getDescriptionText() -> String {
|
|
|
switch authorizationStatus {
|
|
|
case .notDetermined:
|
|
|
return "camera_permission_description".localized
|
|
|
case .denied:
|
|
|
return "camera_permission_denied".localized
|
|
|
case .restricted:
|
|
|
return "camera_permission_restricted".localized
|
|
|
default:
|
|
|
return "camera_permission_unknown".localized
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
#if DEBUG
|
|
|
struct ScannerView_Previews: PreviewProvider {
|
|
|
static var previews: some View {
|
|
|
ScannerView()
|
|
|
}
|
|
|
}
|
|
|
#endif
|