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:
@@ -8,6 +8,7 @@ struct AssignScanView: View {
|
|||||||
|
|
||||||
@State private var paused = false
|
@State private var paused = false
|
||||||
@State private var torchOn = 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 pending: Item? // gescanntes Stück, wartet auf Ort
|
||||||
@State private var status: String?
|
@State private var status: String?
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@@ -16,7 +17,7 @@ struct AssignScanView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
ScannerView(onCode: { code in Task { await handle(code) } },
|
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)
|
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
@@ -52,7 +53,7 @@ struct AssignScanView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
||||||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn) }
|
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ struct CheckInView: View {
|
|||||||
|
|
||||||
@State private var paused = false
|
@State private var paused = false
|
||||||
@State private var torchOn = false
|
@State private var torchOn = false
|
||||||
|
@State private var torchLocked = false
|
||||||
@State private var status: String?
|
@State private var status: String?
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ struct CheckInView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
ScannerView(onCode: { code in Task { await resolve(code) } },
|
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)
|
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
@@ -74,7 +75,7 @@ struct CheckInView: View {
|
|||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
Button("Fertig") { dismiss() }
|
Button("Fertig") { dismiss() }
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn) }
|
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
units = (try? await APIClient.shared.units()) ?? []
|
units = (try? await APIClient.shared.units()) ?? []
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ struct CheckOutView: View {
|
|||||||
|
|
||||||
@State private var paused = false
|
@State private var paused = false
|
||||||
@State private var torchOn = false
|
@State private var torchOn = false
|
||||||
|
@State private var torchLocked = false
|
||||||
@State private var status: String?
|
@State private var status: String?
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var product: Product?
|
@State private var product: Product?
|
||||||
@@ -18,7 +19,7 @@ struct CheckOutView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
ScannerView(onCode: { code in Task { await resolve(code) } },
|
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)
|
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
@@ -56,7 +57,7 @@ struct CheckOutView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
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()) ?? [] }
|
.task { units = (try? await APIClient.shared.units()) ?? [] }
|
||||||
.alert("EAN eingeben", isPresented: $manualCodeShown) {
|
.alert("EAN eingeben", isPresented: $manualCodeShown) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ struct LookupScanView: View {
|
|||||||
|
|
||||||
@State private var paused = false
|
@State private var paused = false
|
||||||
@State private var torchOn = false
|
@State private var torchOn = false
|
||||||
|
@State private var torchLocked = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var item: Item?
|
@State private var item: Item?
|
||||||
@State private var product: Product?
|
@State private var product: Product?
|
||||||
@@ -16,7 +17,7 @@ struct LookupScanView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
ScannerView(onCode: { code in Task { await resolve(code) } },
|
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)
|
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
@@ -36,7 +37,7 @@ struct LookupScanView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
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
|
.sheet(item: $item, onDismiss: { paused = false }) { it in
|
||||||
NavigationStack { ItemEditView(item: it) }
|
NavigationStack { ItemEditView(item: it) }
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ struct ScannerView: UIViewControllerRepresentable {
|
|||||||
var onCode: (String) -> Void
|
var onCode: (String) -> Void
|
||||||
@Binding var isPaused: Bool
|
@Binding var isPaused: Bool
|
||||||
@Binding var torchOn: 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) }
|
func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) }
|
||||||
|
|
||||||
@@ -18,12 +21,16 @@ struct ScannerView: UIViewControllerRepresentable {
|
|||||||
|
|
||||||
func updateUIViewController(_ controller: ScannerViewController, context: Context) {
|
func updateUIViewController(_ controller: ScannerViewController, context: Context) {
|
||||||
context.coordinator.isPaused = isPaused
|
context.coordinator.isPaused = isPaused
|
||||||
|
context.coordinator.torchLocked = torchLocked
|
||||||
|
context.coordinator.torchBinding = $torchOn
|
||||||
controller.setTorch(on: torchOn)
|
controller.setTorch(on: torchOn)
|
||||||
}
|
}
|
||||||
|
|
||||||
final class Coordinator: NSObject, ScannerViewControllerDelegate {
|
final class Coordinator: NSObject, ScannerViewControllerDelegate {
|
||||||
let onCode: (String) -> Void
|
let onCode: (String) -> Void
|
||||||
var isPaused: Bool = false
|
var isPaused: Bool = false
|
||||||
|
var torchLocked: Bool = false
|
||||||
|
var torchBinding: Binding<Bool>?
|
||||||
private var lastCode: String?
|
private var lastCode: String?
|
||||||
private var lastTime: Date = .distantPast
|
private var lastTime: Date = .distantPast
|
||||||
|
|
||||||
@@ -37,21 +44,42 @@ struct ScannerView: UIViewControllerRepresentable {
|
|||||||
lastTime = Date()
|
lastTime = Date()
|
||||||
AudioServicesPlaySystemSound(1057)
|
AudioServicesPlaySystemSound(1057)
|
||||||
onCode(code)
|
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.
|
/// 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 {
|
struct TorchButton: View {
|
||||||
@Binding var isOn: Bool
|
@Binding var isOn: Bool
|
||||||
|
@Binding var locked: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
|
locked = false
|
||||||
isOn.toggle()
|
isOn.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: isOn ? "bolt.fill" : "bolt.slash.fill")
|
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 let session = AVCaptureSession()
|
||||||
private var preview: AVCaptureVideoPreviewLayer?
|
private var preview: AVCaptureVideoPreviewLayer?
|
||||||
private var device: AVCaptureDevice?
|
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,
|
/// Auch krummere Symbologien mitnehmen: ITF-14 kommt auf Umkartons vor,
|
||||||
/// DataMatrix auf kleinen Aufklebern.
|
/// DataMatrix auf kleinen Aufklebern.
|
||||||
@@ -77,6 +107,7 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje
|
|||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .black
|
view.backgroundColor = .black
|
||||||
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(focusOnTap)))
|
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(focusOnTap)))
|
||||||
|
view.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(zoomPinch)))
|
||||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||||
guard granted else { return }
|
guard granted else { return }
|
||||||
DispatchQueue.main.async { self?.configure() }
|
DispatchQueue.main.async { self?.configure() }
|
||||||
@@ -164,6 +195,20 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje
|
|||||||
device.unlockForConfiguration()
|
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.
|
/// Kurzer gelber Fokus-Rahmen an der getippten Stelle als Rueckmeldung.
|
||||||
private func showFocusIndicator(at point: CGPoint) {
|
private func showFocusIndicator(at point: CGPoint) {
|
||||||
let seite: CGFloat = 72
|
let seite: CGFloat = 72
|
||||||
|
|||||||
Reference in New Issue
Block a user