Ein erkannter Textblock konnte mehrere Zeilen enthalten; die Auswahl uebernahm dann mehrzeiligen Text. Jetzt wird strikt in einzelne Zeilen zerlegt, sodass jede Auswahl genau eine Zeile ist. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
270 lines
10 KiB
Swift
270 lines
10 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|