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.

main
v504 2 months ago
parent 2afedafb8a
commit 51d83b4f96

@ -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
}
}
}

@ -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
}
}
}

@ -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)
}
}

@ -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")
}

@ -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<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: -
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

@ -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
}
}
}

@ -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())
}
}

@ -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)
)
}
}
}

@ -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()
}
}
}
Loading…
Cancel
Save