Files
Vorrania/ios/Sources/ScannerView.swift
Scarriffle 1648d1b917 iOS: Kassenzettel in den Scannen-Tab, Markenblau statt System-Blau, weisse Kacheln, luftigere Pruefen-Liste
- Kassenzettel-Kachel vom Listen- in den Scannen-Tab verschoben (passt thematisch).
- App-Akzent auf Markenblau (#3F51B5/#7C8AE8, wie Web/App-Icon) via .tint; alle
  Color.accentColor -> Color.marke. System-Blau verschwindet.
- Kachel-Buttons/Links auf .buttonStyle(.plain): weisse Schrift/Symbole statt
  getoentem Blau.
- Kassenzettel-Pruefen-Liste: je Artikel eine eigene Section (insetGrouped) mit
  Artikel/Menge/Lagerort in eigenen Zeilen - klar gruppiert, nicht mehr gequetscht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 19:05:03 +02:00

275 lines
11 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AVFoundation
import SwiftUI
/// Live-Kamerabild mit Barcode-Erkennung (EAN-8/13, UPC-E, Code128, QR).
/// Meldet jeden erkannten Code einmal erst nach `resume()` wieder.
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) }
func makeUIViewController(context: Context) -> ScannerViewController {
let controller = ScannerViewController()
controller.delegate = context.coordinator
return controller
}
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
init(onCode: @escaping (String) -> Void) { self.onCode = onCode }
func scanner(_ controller: ScannerViewController, didFind code: String) {
guard !isPaused else { return }
// Entprellen: derselbe Code nicht mehrfach in kurzer Folge.
if code == lastCode, Date().timeIntervalSince(lastTime) < 2 { return }
lastCode = code
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.marke)
}
.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)"
)
}
}
protocol ScannerViewControllerDelegate: AnyObject {
func scanner(_ controller: ScannerViewController, didFind code: String)
}
final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
weak var delegate: ScannerViewControllerDelegate?
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.
private static let wantedTypes: [AVMetadataObject.ObjectType] = [
.ean13, .ean8, .upce, .code128, .code39, .code93,
.itf14, .interleaved2of5, .dataMatrix, .pdf417, .aztec, .qr,
]
override func viewDidLoad() {
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() }
}
}
private func configure() {
let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back)
?? AVCaptureDevice.default(for: .video)
guard let camera,
let input = try? AVCaptureDeviceInput(device: camera),
session.canAddInput(input) else { return }
device = camera
session.beginConfiguration()
// Mehr Pixel: kleine oder kontrastarme Codes werden sonst nicht sauber aufgeloest.
if session.canSetSessionPreset(.hd1920x1080) {
session.sessionPreset = .hd1920x1080
} else if session.canSetSessionPreset(.high) {
session.sessionPreset = .high
}
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else {
session.commitConfiguration()
return
}
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
// Nur Typen setzen, die diese Kamera wirklich kann - sonst wirft AVFoundation.
output.metadataObjectTypes = Self.wantedTypes.filter {
output.availableMetadataObjectTypes.contains($0)
}
session.commitConfiguration()
tuneForBarcodes(camera)
let layer = AVCaptureVideoPreviewLayer(session: session)
layer.videoGravity = .resizeAspectFill
layer.frame = view.bounds
view.layer.addSublayer(layer)
preview = layer
start()
}
/// Barcodes liegen dicht vor der Linse und sind manchmal schwach im Kontrast
/// (z. B. dunkelgruen auf weiss). Nahbereich und staendiger Autofokus helfen.
private func tuneForBarcodes(_ camera: AVCaptureDevice) {
guard (try? camera.lockForConfiguration()) != nil else { return }
if camera.isFocusModeSupported(.continuousAutoFocus) {
camera.focusMode = .continuousAutoFocus
}
if camera.isAutoFocusRangeRestrictionSupported {
camera.autoFocusRangeRestriction = .near
}
if camera.isExposureModeSupported(.continuousAutoExposure) {
camera.exposureMode = .continuousAutoExposure
}
// Weiches Nachfuehren ist fuer Video gedacht und verzoegert hier nur.
if camera.isSmoothAutoFocusSupported {
camera.isSmoothAutoFocusEnabled = false
}
camera.unlockForConfiguration()
}
/// Antippen stellt gezielt auf den Code scharf - Rettungsanker fuer Codes,
/// die der Autofokus nicht von selbst findet. Ein kurz aufblitzender Rahmen
/// zeigt, wohin fokussiert wird (wie in der System-Kamera).
@objc private func focusOnTap(_ gesture: UITapGestureRecognizer) {
let location = gesture.location(in: view)
showFocusIndicator(at: location)
guard let device, let preview else { return }
let point = preview.captureDevicePointConverted(fromLayerPoint: location)
guard (try? device.lockForConfiguration()) != nil else { return }
if device.isFocusPointOfInterestSupported, device.isFocusModeSupported(.autoFocus) {
device.focusPointOfInterest = point
device.focusMode = .autoFocus
}
if device.isExposurePointOfInterestSupported, device.isExposureModeSupported(.continuousAutoExposure) {
device.exposurePointOfInterest = point
device.exposureMode = .continuousAutoExposure
}
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
let rahmen = UIView(frame: CGRect(x: 0, y: 0, width: seite, height: seite))
rahmen.center = point
rahmen.backgroundColor = .clear
rahmen.isUserInteractionEnabled = false
rahmen.layer.borderColor = UIColor.systemYellow.cgColor
rahmen.layer.borderWidth = 1.5
rahmen.layer.cornerRadius = 6
rahmen.alpha = 0
rahmen.transform = CGAffineTransform(scaleX: 1.4, y: 1.4)
view.addSubview(rahmen)
UIView.animate(withDuration: 0.2, animations: {
rahmen.alpha = 1
rahmen.transform = .identity
}, completion: { _ in
UIView.animate(withDuration: 0.35, delay: 0.6, options: [], animations: {
rahmen.alpha = 0
}, completion: { _ in rahmen.removeFromSuperview() })
})
}
/// Licht an: hebt den Kontrast bei matten oder farbigen Codes deutlich.
func setTorch(on: Bool) {
guard let device, device.hasTorch, device.isTorchAvailable else { return }
guard device.torchMode != (on ? .on : .off) else { return }
guard (try? device.lockForConfiguration()) != nil else { return }
device.torchMode = on ? .on : .off
device.unlockForConfiguration()
}
func start() {
guard !session.isRunning else { return }
DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.startRunning() }
}
func stop() {
guard session.isRunning else { return }
DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.stopRunning() }
}
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); start() }
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
setTorch(on: false) // Licht nie versehentlich anlassen
stop()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
preview?.frame = view.bounds
}
func metadataOutput(_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection) {
guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let value = object.stringValue else { return }
delegate?.scanner(self, didFind: value)
}
}