Files
Vorrania/ios/Sources/DateScanView.swift
Scarriffle e42e11bf23 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 <noreply@anthropic.com>
2026-07-29 14:35:46 +02:00

267 lines
10 KiB
Swift
Raw 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 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
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
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)
}
}
}