iOS: Scanner – Pinch-Zoom und schlaueres Blitzlicht

- 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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-26 22:42:12 +02:00
parent 944edbccd0
commit 761c131286
5 changed files with 58 additions and 9 deletions

View File

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

View File

@@ -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()) ?? []

View File

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

View File

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

View File

@@ -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<Bool>?
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