diff --git a/MyQrCode/ScannerView/ScannerView.swift b/MyQrCode/ScannerView/ScannerView.swift index 7f99c0e..294cf02 100644 --- a/MyQrCode/ScannerView/ScannerView.swift +++ b/MyQrCode/ScannerView/ScannerView.swift @@ -3,6 +3,7 @@ import AVFoundation import AudioToolbox import Combine import CoreData +import QRCode // MARK: - 主扫描视图 struct ScannerView: View { @@ -14,6 +15,11 @@ struct ScannerView: View { @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] = [] + var body: some View { ZStack { // 相机权限检查 @@ -26,24 +32,28 @@ struct ScannerView: View { ScanningOverlayView( showPreviewPause: showPreviewPause, selectedStyle: $selectedScanningStyle, - detectedCodesCount: scannerViewModel.detectedCodes.count + detectedCodesCount: scannerViewModel.detectedCodes.count, + onImageDecode: { showImagePicker = true } ) // 条码位置标记覆盖层 - if showPreviewPause && !scannerViewModel.detectedCodes.isEmpty { + if showPreviewPause && (!scannerViewModel.detectedCodes.isEmpty || !decodedImageCodes.isEmpty) { CodePositionOverlay( - detectedCodes: scannerViewModel.detectedCodes, + detectedCodes: scannerViewModel.detectedCodes + decodedImageCodes, previewLayer: previewLayer, onCodeSelected: handleCodeSelection ) } // 测试按钮(调试用) - if showPreviewPause && scannerViewModel.detectedCodes.count == 1 { - TestAutoSelectButton( - detectedCode: scannerViewModel.detectedCodes[0], - onSelect: handleCodeSelection - ) + if showPreviewPause && (scannerViewModel.detectedCodes.count + decodedImageCodes.count) == 1 { + let singleCode = scannerViewModel.detectedCodes.first ?? decodedImageCodes.first + if let code = singleCode { + TestAutoSelectButton( + detectedCode: code, + onSelect: handleCodeSelection + ) + } } } else { // 权限未授权时的UI @@ -61,6 +71,9 @@ struct ScannerView: View { .navigationTitle("扫描器") .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(false) + .sheet(isPresented: $showImagePicker) { + ImagePicker(onImageSelected: handleImageDecodeResult) + } .toolbar { ToolbarItem(placement: .navigationBarLeading) { // 手电筒按钮 - 只在相机权限已授权时显示 @@ -246,6 +259,9 @@ struct ScannerView: View { // 重置扫描状态 scannerViewModel.resetDetection() + // 重置图片解码状态 + resetImageDecodeState() + // 恢复相机功能并重新开始扫描 scannerViewModel.resumeCamera() @@ -256,7 +272,8 @@ struct ScannerView: View { logInfo("开始自动选择定时器,条码类型: \(code.type)", className: "ScannerView") DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - guard self.showPreviewPause && self.scannerViewModel.detectedCodes.count == 1 else { + let totalCodes = self.scannerViewModel.detectedCodes.count + self.decodedImageCodes.count + guard self.showPreviewPause && totalCodes == 1 else { logInfo("条件不满足,取消自动选择", className: "ScannerView") return } @@ -265,6 +282,96 @@ struct ScannerView: View { self.handleCodeSelection(code) } } + + // MARK: - 图片解码相关方法 + + /// 处理图片解码结果 + private func handleImageDecodeResult(_ image: UIImage) { + isDecodingImage = true + decodedImageCodes.removeAll() + + logInfo("🔍 开始解码图片", className: "ScannerView") + + // 在后台线程进行解码 + DispatchQueue.global(qos: .userInitiated).async { [self] in + + // 使用QRCode库检测二维码 + let detected = QRCode.DetectQRCodes(in: image) + if detected.count > 0 { + logInfo("✅ 检测到 \(detected.count) 个二维码", className: "ScannerView") + + let results = detected.enumerated().map { index, qrCode in + DetectedCode( + type: "QR Code", + content: qrCode.messageString ?? "未知内容", + bounds: qrCode.bounds + ) + } + + DispatchQueue.main.async { + self.decodedImageCodes = results + self.isDecodingImage = false + self.pauseForPreview() + logInfo("✅ 图片解码完成,共 \(results.count) 个结果", className: "ScannerView") + + // 如果只有一个二维码,自动选择并跳转 + if results.count == 1 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.handleCodeSelection(results[0]) + } + } + } + } else { + DispatchQueue.main.async { + self.isDecodingImage = false + logWarning("❌ 图片中未检测到二维码", className: "ScannerView") + } + } + } + } + + /// 重置图片解码状态 + private func resetImageDecodeState() { + decodedImageCodes.removeAll() + } +} + +// 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) + } + } } #if DEBUG diff --git a/MyQrCode/ScannerView/ScanningOverlayView.swift b/MyQrCode/ScannerView/ScanningOverlayView.swift index c199604..06af714 100644 --- a/MyQrCode/ScannerView/ScanningOverlayView.swift +++ b/MyQrCode/ScannerView/ScanningOverlayView.swift @@ -1,10 +1,12 @@ import SwiftUI +import AudioToolbox // MARK: - 扫描界面覆盖层 struct ScanningOverlayView: View { let showPreviewPause: Bool @Binding var selectedStyle: ScanningLineStyle let detectedCodesCount: Int + let onImageDecode: () -> Void var body: some View { VStack { @@ -26,7 +28,8 @@ struct ScanningOverlayView: View { // 底部按钮区域 ScanningBottomButtonsView( showPreviewPause: showPreviewPause, - selectedStyle: $selectedStyle + selectedStyle: $selectedStyle, + onImageDecode: onImageDecode ) } } @@ -68,6 +71,7 @@ struct ScanningInstructionView: View { struct ScanningBottomButtonsView: View { let showPreviewPause: Bool @Binding var selectedStyle: ScanningLineStyle + let onImageDecode: () -> Void var body: some View { VStack(spacing: 15) { @@ -76,6 +80,34 @@ struct ScanningBottomButtonsView: View { ScanningStyleSelectorView(selectedStyle: $selectedStyle) } + // 图片解码按钮 + if !showPreviewPause { + Button(action: { + onImageDecode() + }) { + HStack(spacing: 8) { + Image(systemName: "photo.on.rectangle.angled") + .font(.system(size: 16, weight: .semibold)) + + Text("图片解码") + .font(.subheadline) + .fontWeight(.medium) + } + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.blue.opacity(0.8)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.blue, lineWidth: 1) + ) + ) + } + .buttonStyle(PlainButtonStyle()) + } + // 移除关闭按钮,因为现在使用导航返回 } .padding(.bottom, 50) @@ -87,19 +119,104 @@ 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) + VStack(spacing: 12) { + // 标题 + Text("扫描线样式") .font(.caption) + .foregroundColor(.white.opacity(0.8)) + .padding(.bottom, 4) + + // 样式选择器 + HStack(spacing: 8) { + ForEach(ScanningLineStyle.allCases, id: \.self) { style in + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + selectedStyle = style + } + + // 添加触觉反馈 + let impactFeedback = UIImpactFeedbackGenerator(style: .light) + impactFeedback.impactOccurred() + }) { + VStack(spacing: 4) { + // 样式预览 + stylePreview(style) + .frame(width: 24, height: 24) + + // 样式名称 + Text(style.localizedName) + .font(.caption2) + .foregroundColor(.white) + } + .frame(width: 60, height: 50) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(selectedStyle == style ? + Color.green.opacity(0.8) : + Color.black.opacity(0.6)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(selectedStyle == style ? + Color.green : + Color.white.opacity(0.3), + lineWidth: selectedStyle == style ? 2 : 1) + ) + ) + } + .buttonStyle(PlainButtonStyle()) + } } } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.black.opacity(0.7)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) + ) .padding(.bottom, 10) } + + // 样式预览 + @ViewBuilder + private func stylePreview(_ style: ScanningLineStyle) -> some View { + switch style { + case .modern: + Rectangle() + .fill( + LinearGradient( + colors: [.blue, .cyan, .blue], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: 20, height: 2) + .shadow(color: .blue, radius: 2, x: 0, y: 0) + case .classic: + Rectangle() + .fill(Color.green) + .frame(width: 16, height: 2) + case .neon: + Rectangle() + .fill(Color.purple) + .frame(width: 18, height: 3) + .shadow(color: .purple, radius: 3, x: 0, y: 0) + case .minimal: + Rectangle() + .fill(Color.white) + .frame(width: 14, height: 1) + case .retro: + Rectangle() + .fill(Color.orange) + .frame(width: 20, height: 2) + .overlay( + Rectangle() + .stroke(Color.yellow, lineWidth: 0.5) + .frame(width: 18, height: 1.5) + ) + } + } } \ No newline at end of file