From 51d83b4f96354d16ec918bf30323ca992d7f72df Mon Sep 17 00:00:00 2001 From: v504 Date: Thu, 28 Aug 2025 18:01:57 +0800 Subject: [PATCH] Refactor localization handling by implementing a centralized localization manager. Update various views to utilize the new manager for improved consistency and maintainability. Enhance user experience by ensuring all UI elements reflect the selected language settings accurately. --- .../ScannerView/CameraPermissionView.swift | 86 +++ .../Views/ScannerView/CameraPreviewView.swift | 34 + .../ScannerView/CodePositionOverlay.swift | 163 +++++ MyQrCode/Views/ScannerView/Models.swift | 22 + MyQrCode/Views/ScannerView/ScannerView.swift | 611 ++++++++++++++++++ .../Views/ScannerView/ScannerViewModel.swift | 430 ++++++++++++ .../Views/ScannerView/ScanningLineView.swift | 152 +++++ .../ScannerView/ScanningOverlayView.swift | 220 +++++++ .../ScannerView/TestAutoSelectButton.swift | 26 + 9 files changed, 1744 insertions(+) create mode 100644 MyQrCode/Views/ScannerView/CameraPermissionView.swift create mode 100644 MyQrCode/Views/ScannerView/CameraPreviewView.swift create mode 100644 MyQrCode/Views/ScannerView/CodePositionOverlay.swift create mode 100644 MyQrCode/Views/ScannerView/Models.swift create mode 100644 MyQrCode/Views/ScannerView/ScannerView.swift create mode 100644 MyQrCode/Views/ScannerView/ScannerViewModel.swift create mode 100644 MyQrCode/Views/ScannerView/ScanningLineView.swift create mode 100644 MyQrCode/Views/ScannerView/ScanningOverlayView.swift create mode 100644 MyQrCode/Views/ScannerView/TestAutoSelectButton.swift diff --git a/MyQrCode/Views/ScannerView/CameraPermissionView.swift b/MyQrCode/Views/ScannerView/CameraPermissionView.swift new file mode 100644 index 0000000..e351914 --- /dev/null +++ b/MyQrCode/Views/ScannerView/CameraPermissionView.swift @@ -0,0 +1,86 @@ +import SwiftUI +import AVFoundation + +// MARK: - 相机权限视图 +struct CameraPermissionView: View { + @EnvironmentObject var languageManager: LanguageManager + 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) + .id(languageManager.refreshTrigger) + + // 描述文本 + Text(getDescriptionText()) + .font(.body) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .padding(.horizontal, 40) + .id(languageManager.refreshTrigger) + + // 操作按钮 + VStack(spacing: 15) { + if authorizationStatus == .notDetermined { + Button(action: onRequestPermission) { + HStack { + Image(systemName: "camera.badge.ellipsis") + Text("request_camera_permission".localized) + .id(languageManager.refreshTrigger) + } + .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) + .id(languageManager.refreshTrigger) + } + .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 + } + } +} \ No newline at end of file diff --git a/MyQrCode/Views/ScannerView/CameraPreviewView.swift b/MyQrCode/Views/ScannerView/CameraPreviewView.swift new file mode 100644 index 0000000..855ba5f --- /dev/null +++ b/MyQrCode/Views/ScannerView/CameraPreviewView.swift @@ -0,0 +1,34 @@ +import SwiftUI +import AVFoundation + +// MARK: - 相机预览视图 +struct CameraPreviewView: UIViewRepresentable { + let session: AVCaptureSession + @Binding var previewLayer: AVCaptureVideoPreviewLayer? + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .black + + let previewLayer = AVCaptureVideoPreviewLayer(session: session) + previewLayer.videoGravity = .resizeAspectFill + view.layer.addSublayer(previewLayer) + + // 设置预览层为绑定变量 + DispatchQueue.main.async { + self.previewLayer = previewLayer + } + + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + guard let previewLayer = uiView.layer.sublayers?.first as? AVCaptureVideoPreviewLayer else { return } + + // 确保预览层尺寸正确 + DispatchQueue.main.async { + previewLayer.frame = uiView.bounds + self.previewLayer = previewLayer + } + } +} \ No newline at end of file diff --git a/MyQrCode/Views/ScannerView/CodePositionOverlay.swift b/MyQrCode/Views/ScannerView/CodePositionOverlay.swift new file mode 100644 index 0000000..460f10c --- /dev/null +++ b/MyQrCode/Views/ScannerView/CodePositionOverlay.swift @@ -0,0 +1,163 @@ +import SwiftUI +import AVFoundation + +// MARK: - 条码位置标记覆盖层 +struct CodePositionOverlay: View { + let detectedCodes: [DetectedCode] + let previewLayer: AVCaptureVideoPreviewLayer? + let onCodeSelected: (DetectedCode) -> Void + + var body: some View { + ZStack { + GeometryReader { geometry in + 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 + } + .ignoresSafeArea() + } + .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) + } + + // 即使会话停止,我们仍然可以使用预览层进行坐标转换 + // 因为预览层的配置仍然有效 + let metadataObject = code.bounds + + // 检查边界值是否有效 + guard metadataObject.width > 0 && metadataObject.height > 0 else { + logWarning("Invalid metadata bounds: \(metadataObject), using screen center", className: "CodePositionMarker") + return CGPoint(x: screenSize.width / 2, y: screenSize.height / 2) + } + + 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) + } +} diff --git a/MyQrCode/Views/ScannerView/Models.swift b/MyQrCode/Views/ScannerView/Models.swift new file mode 100644 index 0000000..172dd95 --- /dev/null +++ b/MyQrCode/Views/ScannerView/Models.swift @@ -0,0 +1,22 @@ +import Foundation +import CoreGraphics + +// MARK: - 检测到的条码数据结构 +struct DetectedCode: Identifiable { + let id = UUID() + let type: String + let content: String + let bounds: CGRect + let source: CodeSource // 添加来源字段 +} + +// MARK: - 条码来源枚举 +enum CodeSource { + case camera // 相机扫描 + case image // 图片解码 +} + +// MARK: - 通知名称扩展 +extension Notification.Name { + static let scannerDidScanCode = Notification.Name("scannerDidScanCode") +} \ No newline at end of file diff --git a/MyQrCode/Views/ScannerView/ScannerView.swift b/MyQrCode/Views/ScannerView/ScannerView.swift new file mode 100644 index 0000000..e31e24a --- /dev/null +++ b/MyQrCode/Views/ScannerView/ScannerView.swift @@ -0,0 +1,611 @@ +import SwiftUI +import AVFoundation +import Combine +import CoreData +import QRCode +import Vision + +// MARK: - 主扫描视图 +struct ScannerView: View { + @StateObject private var scannerViewModel = ScannerViewModel() + @EnvironmentObject var languageManager: LanguageManager + @State private var showPreviewPause = false + @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.isEmpty, + detectedCodesCount: scannerViewModel.detectedCodes.count, + onImageDecode: { showImagePicker = true } + ) + + // 条码位置标记覆盖层 - 显示所有相机扫描的条码选择点 + if showPreviewPause && !scannerViewModel.detectedCodes.isEmpty { + CodePositionOverlay( + detectedCodes: scannerViewModel.detectedCodes, + previewLayer: previewLayer, + onCodeSelected: handleCodeSelection + ) + } + + // 测试按钮(调试用)- 在相机扫描条码时显示 + if showPreviewPause && !scannerViewModel.detectedCodes.isEmpty { + if let code = scannerViewModel.detectedCodes.first { + 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("scanner_title".localized) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(false) + .sheet(isPresented: $showImagePicker) { + ImagePicker( + onImageSelected: handleImageDecodeResult, + shouldProcessImage: false + ) + } + .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)) + .id(languageManager.refreshTrigger) + } + .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) + .id(languageManager.refreshTrigger) + } + .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") + + // 调试信息 + print("🔍 handleDetectedCodes 被调用:") + print(" 条码数量: \(codes.count)") + print(" 条码内容: \(codes.map { "\($0.type): \($0.content)" })") + print(" 条码来源: \(codes.map { $0.source })") + + if codes.count == 1 { + logInfo("单个条码,显示选择点并0.5秒后自动跳转", className: "ScannerView") + pauseForPreview() + // 0.5秒后自动选择单个条码 + autoSelectSingleCode(code: codes[0], delay: 0.5) + } else { + logInfo("多个条码,显示选择点等待用户选择", className: "ScannerView") + pauseForPreview() + } + } + + private func handleOrientationChange() { + logInfo("Screen orientation changed", 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() + + // 调试信息 + print("⏸️ pauseForPreview 被调用:") + print(" showPreviewPause: \(showPreviewPause)") + print(" detectedCodes.count: \(scannerViewModel.detectedCodes.count)") + } + + private func resetToScanning() { + logInfo("🔄 ScannerView 开始重置到扫描状态", className: "ScannerView") + + // 重置UI状态 + showPreviewPause = false + + // 重置扫描状态 + scannerViewModel.resetDetection() + + // 重置图片解码状态 + resetImageDecodeState() + + // 恢复相机功能并重新开始扫描 + scannerViewModel.resumeCamera() + + logInfo("✅ ScannerView 已重置到扫描状态", className: "ScannerView") + } + + private func autoSelectSingleCode(code: DetectedCode, delay: TimeInterval = 1.0) { + logInfo("开始自动选择定时器,条码类型: \(code.type),延迟: \(delay)秒", className: "ScannerView") + + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + 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 ?? "unknown_content".localized, + bounds: qrCode.bounds, + source: .image + ) + } + 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 = "no_codes_detected_in_image".localized + 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 ?? "unknown_content".localized + + logInfo("检测到条形码 #\(index + 1): 类型=\(barcodeType), 内容=\(content)", className: "ScannerView") + + return DetectedCode( + type: barcodeType, + content: content, + bounds: observation.boundingBox, + source: .image + ) + } + } 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 = [] + + 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: - 解码失败提示覆盖层 +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("decode_failed".localized) + .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("reselect_image".localized) + .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() + .environmentObject(LanguageManager.shared) + } + } +} +#endif \ No newline at end of file diff --git a/MyQrCode/Views/ScannerView/ScannerViewModel.swift b/MyQrCode/Views/ScannerView/ScannerViewModel.swift new file mode 100644 index 0000000..afa7ddc --- /dev/null +++ b/MyQrCode/Views/ScannerView/ScannerViewModel.swift @@ -0,0 +1,430 @@ +import SwiftUI +import AVFoundation +import AudioToolbox +import Combine + +// MARK: - 扫描器视图模型 +class ScannerViewModel: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { + @Published var detectedCodes: [DetectedCode] = [] + @Published var showAlert = false + @Published var cameraAuthorizationStatus: AVAuthorizationStatus = .notDetermined + @Published var isTorchOn = false + + var captureSession: AVCaptureSession! + private var metadataOutput: AVCaptureMetadataOutput? + private var videoDevice: AVCaptureDevice? + private var isProcessingDetection = false // 添加处理状态标记 + + 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) + + @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 + } + } + } + } + + 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 + } + + // 保存视频设备引用,用于手电筒控制 + videoDevice = videoCaptureDevice + + 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") + + // 立即设置处理标记,防止新的检测被处理 + isProcessingDetection = true + + // 检查会话是否在运行 + if captureSession?.isRunning == true { + // 立即停止扫描会话 + captureSession?.stopRunning() + logInfo("✅ 扫描会话已停止", className: "ScannerViewModel") + } else { + logInfo("ℹ️ 扫描会话已经停止", className: "ScannerViewModel") + } + } + + /// 暂停相机功能(用于预览暂停状态) + func pauseCamera() { + logInfo("⏸️ 暂停相机功能", className: "ScannerViewModel") + + // 设置处理标记 + isProcessingDetection = true + + // 停止会话(但保留配置) + if captureSession?.isRunning == true { + captureSession?.stopRunning() + logInfo("✅ 相机会话已暂停", className: "ScannerViewModel") + } else { + logInfo("ℹ️ 相机会话已经停止", className: "ScannerViewModel") + } + } + + /// 恢复相机功能(用于恢复扫描) + func resumeCamera() { + logInfo("▶️ 恢复相机功能", className: "ScannerViewModel") + + // 检查相机权限 + guard cameraAuthorizationStatus == .authorized else { + logWarning("❌ 相机权限未授权,无法恢复相机", className: "ScannerViewModel") + return + } + + // 检查会话配置 + if captureSession == nil || captureSession.inputs.isEmpty || captureSession.outputs.isEmpty { + logInfo("🔄 重新设置相机会话", className: "ScannerViewModel") + setupCaptureSession() + } + + // 重置处理标记 + isProcessingDetection = false + + // 启动相机会话 + if captureSession?.isRunning != true { + logInfo("🚀 启动相机会话", className: "ScannerViewModel") + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.captureSession?.startRunning() + + DispatchQueue.main.async { + if self?.captureSession?.isRunning == true { + logInfo("✅ 相机会话启动成功", className: "ScannerViewModel") + } else { + logWarning("⚠️ 相机会话启动失败", className: "ScannerViewModel") + } + } + } + } + + logInfo("✅ 相机功能已恢复", className: "ScannerViewModel") + } + + + + func resetDetection() { + DispatchQueue.main.async { + logInfo("🔄 重置检测状态,清空 detectedCodes", className: "ScannerViewModel") + self.detectedCodes = [] + self.isProcessingDetection = false // 重置处理标记 + } + } + + 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") + + // 先停止当前会话 + if captureSession?.isRunning == true { + logInfo("🔄 停止当前运行的扫描会话", className: "ScannerViewModel") + captureSession?.stopRunning() + } + + // 重置检测状态 + resetDetection() + + // 延迟后重新启动会话 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + logInfo("🔄 准备重新启动扫描会话", className: "ScannerViewModel") + + // 在后台线程启动会话 + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.captureSession?.startRunning() + + // 检查会话状态 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + if self?.captureSession?.isRunning == true { + logInfo("✅ 扫描会话已成功重新启动", className: "ScannerViewModel") + } else { + logWarning("⚠️ 扫描会话启动失败", className: "ScannerViewModel") + } + } + } + } + } + + // MARK: - AVCaptureMetadataOutputObjectsDelegate + + func metadataOutput(_ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection) { + + // 防止重复处理检测结果 + guard !isProcessingDetection else { + logInfo("⚠️ 正在处理检测结果,忽略新的检测", className: "ScannerViewModel") + return + } + + logInfo("metadataOutput 被调用,检测到 \(metadataObjects.count) 个对象", className: "ScannerViewModel") + + // 设置处理标记 + isProcessingDetection = true + + // 震动反馈 + AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + + // 停止扫描 + stopScanning() + + // 处理所有检测到的条码 + var codes: [DetectedCode] = [] + + for metadataObject in metadataObjects { + if let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject, + let stringValue = readableObject.stringValue { + + let codeType = getBarcodeTypeString(from: readableObject.type) + let bounds = readableObject.bounds + + let detectedCode = DetectedCode( + type: codeType, + content: stringValue, + bounds: bounds, + source: .camera + ) + + 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: - 手电筒控制 + + /// 检查设备是否支持手电筒 + var isTorchAvailable: Bool { + guard let device = videoDevice else { return false } + return device.hasTorch && device.isTorchAvailable + } + + /// 切换手电筒状态 + func toggleTorch() { + guard let device = videoDevice else { + logWarning("❌ 没有可用的视频设备", className: "ScannerViewModel") + return + } + + guard device.hasTorch && device.isTorchAvailable else { + logWarning("❌ 设备不支持手电筒", className: "ScannerViewModel") + return + } + + do { + try device.lockForConfiguration() + + if isTorchOn { + // 关闭手电筒 + device.torchMode = .off + isTorchOn = false + logInfo("🔦 手电筒已关闭", className: "ScannerViewModel") + } else { + // 打开手电筒 + try device.setTorchModeOn(level: 1.0) + isTorchOn = true + logInfo("🔦 手电筒已打开", className: "ScannerViewModel") + } + + device.unlockForConfiguration() + + } catch { + logError("❌ 手电筒控制失败: \(error.localizedDescription)", className: "ScannerViewModel") + device.unlockForConfiguration() + } + } + + /// 关闭手电筒 + func turnOffTorch() { + guard let device = videoDevice else { return } + + do { + try device.lockForConfiguration() + device.torchMode = .off + isTorchOn = false + device.unlockForConfiguration() + logInfo("🔦 手电筒已关闭", className: "ScannerViewModel") + } catch { + logError("❌ 关闭手电筒失败: \(error.localizedDescription)", className: "ScannerViewModel") + device.unlockForConfiguration() + } + } + + // MARK: - 条形码类型转换 + + /// 获取条形码类型的可读字符串 + private func getBarcodeTypeString(from metadataType: AVMetadataObject.ObjectType) -> String { + switch metadataType { + case .ean8: + return "EAN-8" + case .ean13: + return "EAN-13" + case .upce: + return "UPC-E" + case .code39: + return "Code 39" + case .code93: + return "Code 93" + 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: + // 处理可能包含前缀的类型字符串 + let typeString = metadataType.rawValue + if typeString.contains("org.gs1.") { + // 移除 org.gs1. 前缀 + let cleanType = typeString.replacingOccurrences(of: "org.gs1.", with: "") + return cleanType.uppercased() + } + return typeString + } + } +} \ No newline at end of file diff --git a/MyQrCode/Views/ScannerView/ScanningLineView.swift b/MyQrCode/Views/ScannerView/ScanningLineView.swift new file mode 100644 index 0000000..f973192 --- /dev/null +++ b/MyQrCode/Views/ScannerView/ScanningLineView.swift @@ -0,0 +1,152 @@ +import SwiftUI + +// MARK: - 扫描线视图 +struct ScanningLineView: View { + @EnvironmentObject var languageManager: LanguageManager + 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: - 扫描线样式枚举 +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 + } + } + + func getLocalizedName(languageManager: LanguageManager) -> String { + switch self { + case .modern: return languageManager.localizedString(for: "style_modern") + case .classic: return languageManager.localizedString(for: "style_classic") + case .neon: return languageManager.localizedString(for: "style_neon") + case .minimal: return languageManager.localizedString(for: "style_minimal") + case .retro: return languageManager.localizedString(for: "style_retro") + } + } +} + +// 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: - 现代扫描线 +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()) + } +} \ No newline at end of file diff --git a/MyQrCode/Views/ScannerView/ScanningOverlayView.swift b/MyQrCode/Views/ScannerView/ScanningOverlayView.swift new file mode 100644 index 0000000..5710fd2 --- /dev/null +++ b/MyQrCode/Views/ScannerView/ScanningOverlayView.swift @@ -0,0 +1,220 @@ +import SwiftUI + +// MARK: - 扫描界面覆盖层 +struct ScanningOverlayView: View { + let showPreviewPause: Bool + let detectedCodesCount: Int + let onImageDecode: () -> Void + + var body: some View { + VStack { + Spacer() + + // 扫描线组件 + if !showPreviewPause { + ScanningLineView(style: .modern) + } + + // 提示文本 + ScanningInstructionView( + showPreviewPause: showPreviewPause, + detectedCodesCount: detectedCodesCount + ) + + Spacer() + + // 底部按钮区域 + ScanningBottomButtonsView( + showPreviewPause: showPreviewPause, + onImageDecode: onImageDecode + ) + } + } +} + +// MARK: - 扫描指令视图 +struct ScanningInstructionView: View { + @EnvironmentObject var languageManager: LanguageManager + let showPreviewPause: Bool + let detectedCodesCount: Int + + var body: some View { + if showPreviewPause { + VStack(spacing: 8) { + Text("detected_codes".localized) + .foregroundColor(.white) + .font(.headline) + .id(languageManager.refreshTrigger) + + if detectedCodesCount == 1 { + Text("auto_result_1s".localized) + .foregroundColor(.green) + .font(.subheadline) + .id(languageManager.refreshTrigger) + } else { + Text("select_code_instruction".localized) + .foregroundColor(.white.opacity(0.8)) + .font(.subheadline) + .id(languageManager.refreshTrigger) + } + } + .padding(.top, 20) + } else { + Text("scan_instruction".localized) + .foregroundColor(.white) + .font(.headline) + .padding(.top, 20) + .id(languageManager.refreshTrigger) + } + } +} + +// MARK: - 扫描底部按钮视图 +struct ScanningBottomButtonsView: View { + let showPreviewPause: Bool + let onImageDecode: () -> Void + + var body: some View { + VStack(spacing: 15) { + // 图片解码按钮 + if !showPreviewPause { + Button(action: { + onImageDecode() + }) { + HStack(spacing: 8) { + Image(systemName: "photo.on.rectangle.angled") + .font(.system(size: 16, weight: .semibold)) + + Text("image_decode".localized) + .font(.subheadline) + .fontWeight(.medium) + } + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.blue.opacity(0.3)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.blue.opacity(0.6), lineWidth: 1) + ) + ) + } + .buttonStyle(PlainButtonStyle()) + } + + // 移除关闭按钮,因为现在使用导航返回 + } + .padding(.bottom, 50) + } +} + +// MARK: - 扫描线样式选择器 +struct ScanningStyleSelectorView: View { + @EnvironmentObject var languageManager: LanguageManager + @Binding var selectedStyle: ScanningLineStyle + + var body: some View { + VStack(spacing: 12) { + // 标题 + Text("scanning_line_style".localized) + .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.getLocalizedName(languageManager: languageManager)) + .font(.caption2) + .foregroundColor(.white) + .id(languageManager.refreshTrigger) + } + .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 diff --git a/MyQrCode/Views/ScannerView/TestAutoSelectButton.swift b/MyQrCode/Views/ScannerView/TestAutoSelectButton.swift new file mode 100644 index 0000000..6520296 --- /dev/null +++ b/MyQrCode/Views/ScannerView/TestAutoSelectButton.swift @@ -0,0 +1,26 @@ +import SwiftUI + +// MARK: - 测试自动选择按钮 +struct TestAutoSelectButton: View { + @EnvironmentObject var languageManager: LanguageManager + let detectedCode: DetectedCode + let onSelect: (DetectedCode) -> Void + + var body: some View { + VStack { + HStack { + Spacer() + Button("test_auto_select".localized) { + onSelect(detectedCode) + } + .id(languageManager.refreshTrigger) + .foregroundColor(.white) + .padding(8) + .background(Color.red) + .cornerRadius(8) + .padding(.trailing, 20) + } + Spacer() + } + } +} \ No newline at end of file