From e42e11bf2331fd70cd77e3a5c20601678415ccdb Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 29 Jul 2026 14:35:46 +0200 Subject: [PATCH] iOS: Name/Marke per Text scannen + Frischedatum ohne Jahr erkennen - Neuer TextScanView: beim Anlegen Name und Marke per Texterkennung einlesen (erkannte Zeilen antippen, wie beim MHD-Scan) - kein Abtippen mehr, wenn ein Produkt nicht in Open Food/Products Facts steht. - BestBeforeText erkennt jetzt auch Tag.Monat ohne Jahr (z.B. "05.01" bei Frischware / "zu verbrauchen bis") und ergaenzt das aktuelle Jahr (bzw. das naechste, falls schon vergangen). Co-Authored-By: Claude Opus 4.8 --- ios/Sources/BestBeforeText.swift | 16 ++++ ios/Sources/DateScanView.swift | 130 +++++++++++++++++++++++++++++++ ios/Sources/ProductViews.swift | 21 +++++ 3 files changed, 167 insertions(+) diff --git a/ios/Sources/BestBeforeText.swift b/ios/Sources/BestBeforeText.swift index 25ea176..4673136 100644 --- a/ios/Sources/BestBeforeText.swift +++ b/ios/Sources/BestBeforeText.swift @@ -31,6 +31,10 @@ enum BestBeforeText { (#"(\d{1,2})[./-](\d{4})"#, ["m", "y"], "month"), (#"(\d{4})[./-](\d{1,2})(?!\d)"#, ["y", "m"], "month"), (#"(\d{1,2})[./-](\d{2})(?!\d)"#, ["m", "Y"], "month"), + // Nur Tag und Monat, kein Jahr (z.B. „05.01" bei Frischware / „zu + // verbrauchen bis"). Das Jahr ergänzt build(). Steht bewusst zuletzt, + // damit Muster mit Jahr Vorrang haben. + (#"(\d{1,2})[./-](\d{1,2})(?!\d)"#, ["d", "m"], "day"), ] /// Alle plausiblen Daten aus einem Textstueck, in Fundreihenfolge und ohne Dubletten. @@ -73,6 +77,18 @@ enum BestBeforeText { jahr = vier } else if let zwei = teile["Y"] { jahr = 2000 + zwei + } else if precision == "day", let tag = teile["d"] { + // Kein Jahr aufgedruckt (Frischware): aktuelles Jahr annehmen; liegt + // das Datum schon in der Vergangenheit, das nächste Jahr nehmen. + let cal = Calendar.current + let aktuell = cal.component(.year, from: Date()) + var probe = DateComponents() + probe.year = aktuell; probe.month = monat; probe.day = tag + if let d = cal.date(from: probe), d < cal.startOfDay(for: Date()) { + jahr = aktuell + 1 + } else { + jahr = aktuell + } } else { return nil } diff --git a/ios/Sources/DateScanView.swift b/ios/Sources/DateScanView.swift index 90f7e1a..e90a2d4 100644 --- a/ios/Sources/DateScanView.swift +++ b/ios/Sources/DateScanView.swift @@ -81,6 +81,136 @@ struct DateScanView: View { } } +/// Liest eine Textzeile (Name/Marke) per Texterkennung ein – die erkannten +/// Zeilen erscheinen zum Antippen, wie beim MHD-Scan. Nützlich, wenn ein Produkt +/// nicht in Open Food/Products Facts steht und man nicht abtippen will. +struct TextScanView: View { + let titel: String + var onPick: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var zeilen: [String] = [] + + static var isSupported: Bool { + DataScannerViewController.isSupported && DataScannerViewController.isAvailable + } + + var body: some View { + ZStack(alignment: .bottom) { + if Self.isSupported { + TextLinesScannerRepresentable { erkannt in + for t in erkannt.reversed() { + let sauber = t.trimmingCharacters(in: .whitespacesAndNewlines) + guard sauber.count >= 2, !zeilen.contains(sauber) else { continue } + zeilen.insert(sauber, at: 0) + } + if zeilen.count > 8 { zeilen = Array(zeilen.prefix(8)) } + } + .ignoresSafeArea() + } else { + Color.black.ignoresSafeArea() + Text("Dieses Gerät unterstützt keine Texterkennung.") + .foregroundStyle(.white) + } + + VStack(spacing: 8) { + if zeilen.isEmpty { + hinweis("Text in den Sucher halten und die passende Zeile antippen.") + } else { + ScrollView { + VStack(spacing: 8) { + ForEach(zeilen, id: \.self) { zeile in + Button { + onPick(zeile) + dismiss() + } label: { + HStack { + Text(zeile).font(.headline).lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: "square.and.arrow.down") + } + .padding() + .background(.thinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + } + } + } + .frame(maxHeight: 300) + } + } + .padding() + } + .navigationTitle(titel) + .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)) + } +} + +/// Wie TextScannerRepresentable, meldet aber die einzelnen erkannten Zeilen +/// (statt sie zu einem String zu verketten) – für die Zeilenauswahl. +private struct TextLinesScannerRepresentable: UIViewControllerRepresentable { + var onLines: ([String]) -> Void + + func makeCoordinator() -> Coordinator { Coordinator(onLines: onLines) } + + 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 onLines: ([String]) -> Void + init(onLines: @escaping ([String]) -> Void) { self.onLines = onLines } + + 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 lines = items.compactMap { item -> String? in + if case .text(let erkannt) = item { return erkannt.transcript } + return nil + } + guard !lines.isEmpty else { return } + onLines(lines) + } + } +} + /// Duenne Huelle um den Text-Scanner von VisionKit. private struct TextScannerRepresentable: UIViewControllerRepresentable { var onText: (String) -> Void diff --git a/ios/Sources/ProductViews.swift b/ios/Sources/ProductViews.swift index f069649..1349b17 100644 --- a/ios/Sources/ProductViews.swift +++ b/ios/Sources/ProductViews.swift @@ -92,6 +92,14 @@ struct ProductFormView: View { // Eigene Felder der (Gegenstands-)Kategorie – schon beim Anlegen ausfüllbar. @State private var fieldDefs: [FieldDefinition] = [] @State private var fieldValues: [String: String] = [:] + // Text-Scan für Name/Marke (statt abtippen), wenn ein Produkt neu ist. + @State private var scanTarget: TextScanTarget? + + enum TextScanTarget: Identifiable { + case name, brand + var id: Int { self == .name ? 0 : 1 } + var titel: String { self == .name ? "Name scannen" : "Marke scannen" } + } private let baseUnits = [("piece", "Stück"), ("gram", "Gramm"), ("milliliter", "Milliliter")] // Beschriftungen kommen sonst aus DisplaySettings.baseUnitLabel. @@ -109,7 +117,13 @@ struct ProductFormView: View { Section("Artikel") { LabeledField(label: "Barcode", text: $barcode, keyboard: .numberPad) LabeledField(label: "Name", text: $name) + if TextScanView.isSupported { + Button { scanTarget = .name } label: { Label("Name scannen", systemImage: "text.viewfinder") } + } LabeledField(label: "Marke", text: $brand) + if TextScanView.isSupported { + Button { scanTarget = .brand } label: { Label("Marke scannen", systemImage: "text.viewfinder") } + } } Section { Picker("Typ", selection: $modus) { @@ -224,6 +238,13 @@ struct ProductFormView: View { CameraPicker(isPresented: $showCamera) { image in pendingImage = image } .ignoresSafeArea() } + .fullScreenCover(item: $scanTarget) { target in + NavigationStack { + TextScanView(titel: target.titel) { text in + if target == .name { name = text } else { brand = text } + } + } + } .onChange(of: pickerItem) { item in guard let item else { return } Task {