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)) } } /// 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 // Ein erkannter Textblock kann mehrere Zeilen umfassen – strikt // in einzelne Zeilen zerlegen, damit jede Auswahl genau eine ist. let einzeln = erkannt.flatMap { $0.components(separatedBy: .newlines) } for t in einzeln.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 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) } } }