MHD per Texterkennung einlesen

In der Chargenzeile beim Einlagern gibt es einen Knopf "MHD scannen". Er
oeffnet die Texterkennung von VisionKit; erkannte Daten erscheinen als
Vorschlag und werden erst uebernommen, wenn man sie antippt. Bewusst kein
automatisches Uebernehmen: Eine Fehllesung beim MHD faellt sonst erst auf,
wenn die Ware bereits falsch einsortiert ist.

Der Parser deckt die Schreibweisen ab, die auf Verpackungen vorkommen -
Tagesdaten (12.09.2026, 2026-09-12, 12/09/2026, 12.09.26) und Monatsangaben
(09/2026, 09.2026, 2026-09, 09/26). Erkennt der Aufdruck nur Monat und Jahr,
stellt sich die Eingabe sichtbar auf Monat/Jahr um.

Damit aus Chargennummern und EAN-Codes keine Phantasiedaten werden, greifen
mehrere Einschraenkungen: Muster mit Tag werden zuerst geprueft, ein bereits
erkannter Textabschnitt wird nicht erneut ausgewertet, Monate ausserhalb 1-12
entfallen und das Jahr muss im Fenster von fuenf Jahren rueckwaerts bis
dreissig vorwaerts liegen.

Der Knopf erscheint nur, wenn das Geraet Texterkennung unterstuetzt
(Neural Engine); sonst bleibt die Eingabe von Hand.

Getestet: Der Parser wurde eigenstaendig uebersetzt und gegen 14 Faelle
geprueft, alle korrekt - darunter, dass "12.09.2026" nicht als Monatsangabe
gelesen wird, dass eine EAN keine Treffer erzeugt und dass unplausible Jahre
und Monate verworfen werden. Der iOS-Geraetebuild laeuft fehlerfrei durch.
Die Erkennung an einer echten Verpackung habe ich nicht selbst ausprobiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 18:10:24 +02:00
parent f682993b62
commit c4acc8bec7
5 changed files with 398 additions and 0 deletions

View File

@@ -0,0 +1,136 @@
import SwiftUI
import VisionKit
/// Liest das MHD per Texterkennung von der Verpackung ab.
///
/// Gefundene Daten werden als Vorschlag angezeigt; uebernommen wird erst,
/// was der Benutzer antippt.
struct DateScanView: View {
var onPick: (Date, String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var matches: [BestBeforeText.Match] = []
/// Texterkennung braucht Geraete mit Neural Engine; auf aelteren fehlt sie.
static var isSupported: Bool {
DataScannerViewController.isSupported && DataScannerViewController.isAvailable
}
var body: some View {
ZStack(alignment: .bottom) {
if Self.isSupported {
TextScannerRepresentable { text in
let neu = BestBeforeText.matches(in: text)
guard !neu.isEmpty else { return }
// Neueste Funde vorn, hoechstens eine Handvoll anzeigen.
for treffer in neu where !matches.contains(where: {
$0.date == treffer.date && $0.precision == treffer.precision
}) {
matches.insert(treffer, at: 0)
}
if matches.count > 5 { matches = Array(matches.prefix(5)) }
}
.ignoresSafeArea()
} else {
Color.black.ignoresSafeArea()
Text("Dieses Gerät unterstützt keine Texterkennung.")
.foregroundStyle(.white)
}
VStack(spacing: 10) {
if matches.isEmpty {
hinweis("Das Mindesthaltbarkeitsdatum in den Sucher halten.")
} else {
ForEach(matches) { treffer in
Button {
onPick(treffer.date, treffer.precision)
dismiss()
} label: {
HStack {
Image(systemName: "calendar")
Text(treffer.label).font(.headline)
Spacer()
Text("Übernehmen").font(.subheadline)
}
.padding()
.frame(maxWidth: .infinity)
.background(.thinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
hinweis("Bitte gegen den Aufdruck prüfen.")
}
}
.padding()
}
.navigationTitle("MHD scannen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
}
}
private func hinweis(_ text: String) -> some View {
Text(text)
.font(.caption)
.padding(10)
.frame(maxWidth: .infinity)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
/// Duenne Huelle um den Text-Scanner von VisionKit.
private struct TextScannerRepresentable: UIViewControllerRepresentable {
var onText: (String) -> Void
func makeCoordinator() -> Coordinator { Coordinator(onText: onText) }
func makeUIViewController(context: Context) -> DataScannerViewController {
let controller = DataScannerViewController(
recognizedDataTypes: [.text()],
qualityLevel: .accurate,
recognizesMultipleItems: true,
isHighFrameRateTrackingEnabled: false,
isHighlightingEnabled: true
)
controller.delegate = context.coordinator
return controller
}
func updateUIViewController(_ controller: DataScannerViewController, context: Context) {
guard !controller.isScanning else { return }
try? controller.startScanning()
}
static func dismantleUIViewController(_ controller: DataScannerViewController, coordinator: Coordinator) {
controller.stopScanning()
}
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
let onText: (String) -> Void
init(onText: @escaping (String) -> Void) { self.onText = onText }
func dataScanner(_ scanner: DataScannerViewController,
didAdd addedItems: [RecognizedItem],
allItems: [RecognizedItem]) {
handle(allItems)
}
func dataScanner(_ scanner: DataScannerViewController,
didUpdate updatedItems: [RecognizedItem],
allItems: [RecognizedItem]) {
handle(allItems)
}
private func handle(_ items: [RecognizedItem]) {
let text = items.compactMap { item -> String? in
if case .text(let erkannt) = item { return erkannt.transcript }
return nil
}.joined(separator: " ")
guard !text.isEmpty else { return }
onText(text)
}
}
}