From 761c13128606490bfb5b142e529aa56b5924e417 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 26 Jul 2026 22:42:12 +0200 Subject: [PATCH] =?UTF-8?q?iOS:=20Scanner=20=E2=80=93=20Pinch-Zoom=20und?= =?UTF-8?q?=20schlaueres=20Blitzlicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Zwei-Finger-Pinch zoomt im Kamerabild naeher an einen Code heran (digital, auf Faktor 6 begrenzt). - Blitz geht nach dem ersten erfolgreichen Scan wieder aus. Per Langdruck auf den Blitz-Knopf bleibt er dauerhaft an (gesperrt, gelb); Tippen schaltet aus und hebt die Sperre auf. Gilt fuer alle Scan-Bildschirme. Co-Authored-By: Claude Opus 4.8 --- ios/Sources/AssignScanView.swift | 5 ++-- ios/Sources/CheckInView.swift | 5 ++-- ios/Sources/CheckOutView.swift | 5 ++-- ios/Sources/LookupScanView.swift | 5 ++-- ios/Sources/ScannerView.swift | 47 +++++++++++++++++++++++++++++++- 5 files changed, 58 insertions(+), 9 deletions(-) diff --git a/ios/Sources/AssignScanView.swift b/ios/Sources/AssignScanView.swift index 73e3ddd..ce446ce 100644 --- a/ios/Sources/AssignScanView.swift +++ b/ios/Sources/AssignScanView.swift @@ -8,6 +8,7 @@ struct AssignScanView: View { @State private var paused = false @State private var torchOn = false + @State private var torchLocked = false @State private var pending: Item? // gescanntes Stück, wartet auf Ort @State private var status: String? @State private var error: String? @@ -16,7 +17,7 @@ struct AssignScanView: View { ScrollView { VStack(spacing: 14) { ScannerView(onCode: { code in Task { await handle(code) } }, - isPaused: $paused, torchOn: $torchOn) + isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked) .aspectRatio(4.0 / 3.0, contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 16)) .padding(.horizontal) @@ -52,7 +53,7 @@ struct AssignScanView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } - ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn) } + ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) } } } diff --git a/ios/Sources/CheckInView.swift b/ios/Sources/CheckInView.swift index 0b0b678..d4213e8 100644 --- a/ios/Sources/CheckInView.swift +++ b/ios/Sources/CheckInView.swift @@ -5,6 +5,7 @@ struct CheckInView: View { @State private var paused = false @State private var torchOn = false + @State private var torchLocked = false @State private var status: String? @State private var error: String? @@ -27,7 +28,7 @@ struct CheckInView: View { ScrollView { VStack(spacing: 14) { ScannerView(onCode: { code in Task { await resolve(code) } }, - isPaused: $paused, torchOn: $torchOn) + isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked) .aspectRatio(4.0 / 3.0, contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 16)) .padding(.horizontal) @@ -74,7 +75,7 @@ struct CheckInView: View { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } - ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn) } + ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) } } .task { units = (try? await APIClient.shared.units()) ?? [] diff --git a/ios/Sources/CheckOutView.swift b/ios/Sources/CheckOutView.swift index dd4f8f7..62c9684 100644 --- a/ios/Sources/CheckOutView.swift +++ b/ios/Sources/CheckOutView.swift @@ -5,6 +5,7 @@ struct CheckOutView: View { @State private var paused = false @State private var torchOn = false + @State private var torchLocked = false @State private var status: String? @State private var error: String? @State private var product: Product? @@ -18,7 +19,7 @@ struct CheckOutView: View { ScrollView { VStack(spacing: 14) { ScannerView(onCode: { code in Task { await resolve(code) } }, - isPaused: $paused, torchOn: $torchOn) + isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked) .aspectRatio(4.0 / 3.0, contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 16)) .padding(.horizontal) @@ -56,7 +57,7 @@ struct CheckOutView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } - ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn) } + ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) } } .task { units = (try? await APIClient.shared.units()) ?? [] } .alert("EAN eingeben", isPresented: $manualCodeShown) { diff --git a/ios/Sources/LookupScanView.swift b/ios/Sources/LookupScanView.swift index 3c02513..3216fdd 100644 --- a/ios/Sources/LookupScanView.swift +++ b/ios/Sources/LookupScanView.swift @@ -8,6 +8,7 @@ struct LookupScanView: View { @State private var paused = false @State private var torchOn = false + @State private var torchLocked = false @State private var error: String? @State private var item: Item? @State private var product: Product? @@ -16,7 +17,7 @@ struct LookupScanView: View { ScrollView { VStack(spacing: 14) { ScannerView(onCode: { code in Task { await resolve(code) } }, - isPaused: $paused, torchOn: $torchOn) + isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked) .aspectRatio(4.0 / 3.0, contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 16)) .padding(.horizontal) @@ -36,7 +37,7 @@ struct LookupScanView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } - ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn) } + ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) } } .sheet(item: $item, onDismiss: { paused = false }) { it in NavigationStack { ItemEditView(item: it) } diff --git a/ios/Sources/ScannerView.swift b/ios/Sources/ScannerView.swift index d98329f..49d975f 100644 --- a/ios/Sources/ScannerView.swift +++ b/ios/Sources/ScannerView.swift @@ -7,6 +7,9 @@ struct ScannerView: UIViewControllerRepresentable { var onCode: (String) -> Void @Binding var isPaused: Bool @Binding var torchOn: Bool + /// Per Langdruck gesperrter Blitz bleibt auch nach einem Scan an. Standard: + /// aus nach dem ersten Treffer. + @Binding var torchLocked: Bool func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) } @@ -18,12 +21,16 @@ struct ScannerView: UIViewControllerRepresentable { func updateUIViewController(_ controller: ScannerViewController, context: Context) { context.coordinator.isPaused = isPaused + context.coordinator.torchLocked = torchLocked + context.coordinator.torchBinding = $torchOn controller.setTorch(on: torchOn) } final class Coordinator: NSObject, ScannerViewControllerDelegate { let onCode: (String) -> Void var isPaused: Bool = false + var torchLocked: Bool = false + var torchBinding: Binding? private var lastCode: String? private var lastTime: Date = .distantPast @@ -37,21 +44,42 @@ struct ScannerView: UIViewControllerRepresentable { lastTime = Date() AudioServicesPlaySystemSound(1057) onCode(code) + // Blitz nach dem Scan ausschalten – ausser er ist per Langdruck gesperrt. + if !torchLocked, torchBinding?.wrappedValue == true { + DispatchQueue.main.async { [weak self] in self?.torchBinding?.wrappedValue = false } + } } } } /// Licht-Schalter fuer die Scan-Bildschirme. +/// +/// Tippen: an/aus – angeschaltet geht der Blitz nach dem naechsten Scan wieder +/// aus. Langdruck: dauerhaft an (gesperrt, gelb), bleibt auch nach Scans an; +/// erneutes Tippen schaltet aus und hebt die Sperre auf. struct TorchButton: View { @Binding var isOn: Bool + @Binding var locked: Bool var body: some View { Button { + locked = false isOn.toggle() } label: { Image(systemName: isOn ? "bolt.fill" : "bolt.slash.fill") + .foregroundStyle(locked ? Color.yellow : Color.accentColor) } - .accessibilityLabel(isOn ? "Licht ausschalten" : "Licht einschalten") + .simultaneousGesture( + LongPressGesture(minimumDuration: 0.4).onEnded { _ in + isOn = true + locked = true + } + ) + .accessibilityLabel( + isOn + ? (locked ? "Licht dauerhaft an, zum Ausschalten tippen" : "Licht aus") + : "Licht ein (bleibt bis zum nächsten Scan)" + ) } } @@ -65,6 +93,8 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje private let session = AVCaptureSession() private var preview: AVCaptureVideoPreviewLayer? private var device: AVCaptureDevice? + /// Zoomfaktor beim Beginn einer Pinch-Geste (Ausgangspunkt fuers Skalieren). + private var zoomBasis: CGFloat = 1.0 /// Auch krummere Symbologien mitnehmen: ITF-14 kommt auf Umkartons vor, /// DataMatrix auf kleinen Aufklebern. @@ -77,6 +107,7 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje super.viewDidLoad() view.backgroundColor = .black view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(focusOnTap))) + view.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(zoomPinch))) AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in guard granted else { return } DispatchQueue.main.async { self?.configure() } @@ -164,6 +195,20 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje device.unlockForConfiguration() } + /// Zwei-Finger-Pinch zoomt naeher an einen Code heran (rein digital, auf ein + /// sinnvolles Maximum begrenzt – zu viel Zoom macht den Code unscharf). + @objc private func zoomPinch(_ gesture: UIPinchGestureRecognizer) { + guard let device else { return } + if gesture.state == .began { + zoomBasis = device.videoZoomFactor + } + let maxZoom = min(device.activeFormat.videoMaxZoomFactor, 6.0) + let neu = max(1.0, min(zoomBasis * gesture.scale, maxZoom)) + guard (try? device.lockForConfiguration()) != nil else { return } + device.videoZoomFactor = neu + device.unlockForConfiguration() + } + /// Kurzer gelber Fokus-Rahmen an der getippten Stelle als Rueckmeldung. private func showFocusIndicator(at point: CGPoint) { let seite: CGFloat = 72