You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

633 lines
25 KiB

import SwiftUI
import AVFoundation
import AudioToolbox
import Combine
import CoreData
import QRCode
import Vision
// MARK: -
struct ScannerView: View {
@StateObject private var scannerViewModel = ScannerViewModel()
@State private var showPreviewPause = false
@State private var screenOrientation = UIDevice.current.orientation
@State private var previewLayer: AVCaptureVideoPreviewLayer?
@State private var navigateToDetail = false
@State private var selectedHistoryItem: HistoryItem?
//
@State private var showImagePicker = false
@State private var isDecodingImage = false
@State private var decodedImageCodes: [DetectedCode] = []
@State private var showDecodeFailure = false
@State private var decodeFailureMessage = ""
var body: some View {
ZStack {
//
if scannerViewModel.cameraAuthorizationStatus == .authorized {
//
CameraPreviewView(session: scannerViewModel.captureSession, previewLayer: $previewLayer)
.ignoresSafeArea()
//
ScanningOverlayView(
showPreviewPause: showPreviewPause &&
((scannerViewModel.detectedCodes.count > 1) || (decodedImageCodes.count > 1)),
detectedCodesCount: scannerViewModel.detectedCodes.count + decodedImageCodes.count,
onImageDecode: { showImagePicker = true }
)
// -
if showPreviewPause &&
((!scannerViewModel.detectedCodes.isEmpty && scannerViewModel.detectedCodes.count > 1) ||
(!decodedImageCodes.isEmpty && decodedImageCodes.count > 1)) {
CodePositionOverlay(
detectedCodes: scannerViewModel.detectedCodes + decodedImageCodes,
previewLayer: previewLayer,
onCodeSelected: handleCodeSelection
)
}
// -
if showPreviewPause &&
((scannerViewModel.detectedCodes.count > 1) || (decodedImageCodes.count > 1)) {
let singleCode = scannerViewModel.detectedCodes.first ?? decodedImageCodes.first
if let code = singleCode {
TestAutoSelectButton(
detectedCode: code,
onSelect: handleCodeSelection
)
}
}
//
if showDecodeFailure {
DecodeFailureOverlay(
message: decodeFailureMessage,
onDismiss: {
showDecodeFailure = false
}
)
}
} else {
// UI
CameraPermissionView(
authorizationStatus: scannerViewModel.cameraAuthorizationStatus,
onRequestPermission: {
scannerViewModel.refreshCameraPermission()
},
onOpenSettings: {
scannerViewModel.openSettings()
}
)
}
}
.navigationTitle("扫描器")
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(false)
.sheet(isPresented: $showImagePicker) {
ImagePicker(onImageSelected: handleImageDecodeResult)
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
// -
if scannerViewModel.cameraAuthorizationStatus == .authorized && scannerViewModel.isTorchAvailable {
Button(action: {
logInfo("🔦 用户点击手电筒按钮", className: "ScannerView")
//
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
impactFeedback.impactOccurred()
scannerViewModel.toggleTorch()
}) {
Image(systemName: scannerViewModel.isTorchOn ? "bolt.fill" : "bolt")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(scannerViewModel.isTorchOn ? .yellow : .blue)
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
// -
if showPreviewPause {
Button(action: {
logInfo("🔄 用户点击工具栏重新扫描按钮", className: "ScannerView")
//
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
impactFeedback.impactOccurred()
resetToScanning()
}) {
HStack(spacing: 6) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 16, weight: .semibold))
Text("rescan_button".localized)
.font(.system(size: 14, weight: .medium))
}
.foregroundColor(.blue)
}
}
}
}
.onAppear {
//
if scannerViewModel.cameraAuthorizationStatus == .authorized {
scannerViewModel.startScanning()
}
}
.onDisappear {
scannerViewModel.stopScanning()
// 退
if scannerViewModel.isTorchOn {
scannerViewModel.turnOffTorch()
}
}
.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()
}
.background(
NavigationLink(
destination: Group {
if let historyItem = selectedHistoryItem {
//
if historyItem.dataType == DataType.qrcode.rawValue {
QRCodeDetailView(historyItem: historyItem)
.onDisappear {
//
logInfo("🔄 从二维码详情页返回,重新开始扫描", className: "ScannerView")
resetToScanning()
}
} else {
BarcodeDetailView(historyItem: historyItem)
.onDisappear {
//
logInfo("🔄 从条形码详情页返回,重新开始扫描", className: "ScannerView")
resetToScanning()
}
}
}
},
isActive: $navigateToDetail
) {
EmptyView()
}
)
}
// 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")
//
scannerViewModel.stopScanning()
logInfo("🛑 已停止扫描功能", className: "ScannerView")
// HistoryItem Core Data
let historyItem = createHistoryItem(from: selectedCode)
//
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
//
self.selectedHistoryItem = historyItem
self.navigateToDetail = true
}
//
let formattedResult = "类型: \(selectedCode.type)\n内容: \(selectedCode.content)"
logInfo(" 格式化结果: \(formattedResult)", className: "ScannerView")
NotificationCenter.default.post(name: .scannerDidScanCode, object: formattedResult)
}
private func createHistoryItem(from detectedCode: DetectedCode) -> HistoryItem {
let context = CoreDataManager.shared.container.viewContext
let historyItem = HistoryItem(context: context)
historyItem.id = UUID()
historyItem.content = detectedCode.content
historyItem.dataSource = DataSource.scanned.rawValue
historyItem.createdAt = Date()
historyItem.isFavorite = false
//
let isQRCode = detectedCode.type.lowercased().contains("qr") ||
detectedCode.type.lowercased().contains("二维码") ||
detectedCode.type.lowercased().contains("data matrix") ||
detectedCode.type.lowercased().contains("aztec")
if isQRCode {
//
historyItem.dataType = DataType.qrcode.rawValue
//
let parsedData = QRCodeParser.parseQRCode(detectedCode.content)
historyItem.qrCodeType = parsedData.type.rawValue
historyItem.barcodeType = nil //
logInfo("📱 创建二维码历史记录,类型: \(detectedCode.type)", className: "ScannerView")
} else {
//
historyItem.dataType = DataType.barcode.rawValue
historyItem.barcodeType = detectedCode.type
historyItem.qrCodeType = nil //
logInfo("📊 创建条形码历史记录,类型: \(detectedCode.type)", className: "ScannerView")
}
// Core Data
CoreDataManager.shared.addHistoryItem(historyItem)
logInfo("✅ 已创建并保存历史记录项", className: "ScannerView")
return historyItem
}
private func pauseForPreview() {
showPreviewPause = true
//
scannerViewModel.pauseCamera()
}
private func resetToScanning() {
logInfo("🔄 ScannerView 开始重置到扫描状态", className: "ScannerView")
// UI
showPreviewPause = false
//
scannerViewModel.resetDetection()
//
resetImageDecodeState()
//
scannerViewModel.resumeCamera()
logInfo("✅ ScannerView 已重置到扫描状态", className: "ScannerView")
}
private func autoSelectSingleCode(code: DetectedCode) {
logInfo("开始自动选择定时器,条码类型: \(code.type)", className: "ScannerView")
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
let totalCodes = self.scannerViewModel.detectedCodes.count + self.decodedImageCodes.count
guard self.showPreviewPause && totalCodes == 1 else {
logInfo("条件不满足,取消自动选择", className: "ScannerView")
return
}
logInfo("条件满足,执行自动选择", className: "ScannerView")
self.handleCodeSelection(code)
}
}
// MARK: -
///
private func handleImageDecodeResult(_ image: UIImage) {
isDecodingImage = true
decodedImageCodes.removeAll()
showDecodeFailure = false
decodeFailureMessage = ""
logInfo("🔍 开始解码图片", className: "ScannerView")
// 线
DispatchQueue.global(qos: .userInitiated).async { [self] in
var allResults: [DetectedCode] = []
// 使Vision
if let cgImage = image.cgImage {
let barcodeResults = detectBarcodes(in: cgImage)
if !barcodeResults.isEmpty {
logInfo("✅ 检测到 \(barcodeResults.count) 个条码", className: "ScannerView")
allResults.append(contentsOf: barcodeResults)
}
}
// 使QRCode
if allResults.isEmpty {
let detectedQR = QRCode.DetectQRCodes(in: image)
if detectedQR.count > 0 {
logInfo("✅ 使用QRCode库检测到 \(detectedQR.count) 个二维码", className: "ScannerView")
let qrResults = detectedQR.enumerated().map { index, qrCode in
DetectedCode(
type: "QR Code",
content: qrCode.messageString ?? "未知内容",
bounds: qrCode.bounds
)
}
allResults.append(contentsOf: qrResults)
}
}
DispatchQueue.main.async {
if !allResults.isEmpty {
//
let uniqueResults = self.removeDuplicateCodes(allResults)
self.decodedImageCodes = uniqueResults
self.isDecodingImage = false
logInfo("✅ 图片解码完成,去重后共 \(uniqueResults.count) 个结果", className: "ScannerView")
//
if uniqueResults.count == 1 {
//
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.handleCodeSelection(uniqueResults[0])
}
} else if uniqueResults.count > 1 {
//
self.pauseForPreview()
logInfo("📱 检测到多个条码,显示选择点", className: "ScannerView")
}
} else {
self.isDecodingImage = false
self.decodeFailureMessage = "图片中未检测到二维码或条形码"
self.showDecodeFailure = true
logWarning("❌ 图片中未检测到二维码或条形码", className: "ScannerView")
}
}
}
}
///
private func resetImageDecodeState() {
decodedImageCodes.removeAll()
showDecodeFailure = false
decodeFailureMessage = ""
}
/// 使Vision
private func detectBarcodes(in cgImage: CGImage) -> [DetectedCode] {
let request = VNDetectBarcodesRequest { request, error in
if let error = error {
logWarning("条形码检测错误: \(error.localizedDescription)", className: "ScannerView")
return
}
}
//
request.symbologies = [
.ean8,
.ean13,
.upce,
.code39,
.code39Checksum,
.code39FullASCII,
.code39FullASCIIChecksum,
.code93,
.code93i,
.code128,
.itf14,
.pdf417,
.qr,
.dataMatrix,
.aztec
]
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
let results = request.results ?? []
guard !results.isEmpty else {
return []
}
return results.enumerated().map { index, observation in
let barcodeType = getBarcodeTypeString(from: observation.symbology)
let content = observation.payloadStringValue ?? "未知内容"
logInfo("检测到条形码 #\(index + 1): 类型=\(barcodeType), 内容=\(content)", className: "ScannerView")
return DetectedCode(
type: barcodeType,
content: content,
bounds: observation.boundingBox
)
}
} catch {
logWarning("条形码检测请求失败: \(error.localizedDescription)", className: "ScannerView")
return []
}
}
///
private func getBarcodeTypeString(from symbology: VNBarcodeSymbology) -> String {
switch symbology {
case .ean8:
return "EAN-8"
case .ean13:
return "EAN-13"
case .upce:
return "UPC-E"
case .code39:
return "Code 39"
case .code39Checksum:
return "Code 39 (Checksum)"
case .code39FullASCII:
return "Code 39 (Full ASCII)"
case .code39FullASCIIChecksum:
return "Code 39 (Full ASCII + Checksum)"
case .code93:
return "Code 93"
case .code93i:
return "Code 93i"
case .code128:
return "Code 128"
case .itf14:
return "ITF-14"
case .pdf417:
return "PDF417"
case .qr:
return "QR Code"
case .dataMatrix:
return "Data Matrix"
case .aztec:
return "Aztec"
default:
return "Unknown Barcode"
}
}
///
private func removeDuplicateCodes(_ codes: [DetectedCode]) -> [DetectedCode] {
var uniqueCodes: [DetectedCode] = []
var seenContents: Set<String> = []
for code in codes {
if !seenContents.contains(code.content) {
seenContents.insert(code.content)
uniqueCodes.append(code)
} else {
logInfo("🔄 发现重复条码,内容: \(code.content),已跳过", className: "ScannerView")
}
}
return uniqueCodes
}
}
// MARK: - iOS 15
struct ImagePicker: UIViewControllerRepresentable {
let onImageSelected: (UIImage) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .photoLibrary
picker.allowsEditing = false
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
if let image = info[.originalImage] as? UIImage {
parent.onImageSelected(image)
}
picker.dismiss(animated: true)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
}
}
// MARK: -
struct DecodeFailureOverlay: View {
let message: String
let onDismiss: () -> Void
var body: some View {
ZStack {
//
Color.black.opacity(0.7)
.ignoresSafeArea()
.onTapGesture {
onDismiss()
}
//
VStack(spacing: 20) {
//
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 50))
.foregroundColor(.orange)
//
Text("解码失败")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.white)
//
Text(message)
.font(.body)
.foregroundColor(.white.opacity(0.8))
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
//
Button(action: {
onDismiss()
}) {
HStack(spacing: 8) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 16, weight: .semibold))
Text("重新选择图片")
.font(.headline)
.fontWeight(.medium)
}
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.blue.opacity(0.8))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.blue, lineWidth: 1)
)
)
}
.buttonStyle(PlainButtonStyle())
}
.padding(30)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(Color(.systemGray6).opacity(0.9))
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
)
.shadow(color: .black.opacity(0.3), radius: 20, x: 0, y: 10)
}
.zIndex(2000) //
.transition(.opacity.combined(with: .scale))
}
}
#if DEBUG
struct ScannerView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
ScannerView()
}
}
}
#endif