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>
This commit is contained in:
@@ -31,6 +31,10 @@ enum BestBeforeText {
|
|||||||
(#"(\d{1,2})[./-](\d{4})"#, ["m", "y"], "month"),
|
(#"(\d{1,2})[./-](\d{4})"#, ["m", "y"], "month"),
|
||||||
(#"(\d{4})[./-](\d{1,2})(?!\d)"#, ["y", "m"], "month"),
|
(#"(\d{4})[./-](\d{1,2})(?!\d)"#, ["y", "m"], "month"),
|
||||||
(#"(\d{1,2})[./-](\d{2})(?!\d)"#, ["m", "Y"], "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.
|
/// Alle plausiblen Daten aus einem Textstueck, in Fundreihenfolge und ohne Dubletten.
|
||||||
@@ -73,6 +77,18 @@ enum BestBeforeText {
|
|||||||
jahr = vier
|
jahr = vier
|
||||||
} else if let zwei = teile["Y"] {
|
} else if let zwei = teile["Y"] {
|
||||||
jahr = 2000 + zwei
|
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 {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
/// Duenne Huelle um den Text-Scanner von VisionKit.
|
||||||
private struct TextScannerRepresentable: UIViewControllerRepresentable {
|
private struct TextScannerRepresentable: UIViewControllerRepresentable {
|
||||||
var onText: (String) -> Void
|
var onText: (String) -> Void
|
||||||
|
|||||||
@@ -92,6 +92,14 @@ struct ProductFormView: View {
|
|||||||
// Eigene Felder der (Gegenstands-)Kategorie – schon beim Anlegen ausfüllbar.
|
// Eigene Felder der (Gegenstands-)Kategorie – schon beim Anlegen ausfüllbar.
|
||||||
@State private var fieldDefs: [FieldDefinition] = []
|
@State private var fieldDefs: [FieldDefinition] = []
|
||||||
@State private var fieldValues: [String: String] = [:]
|
@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")]
|
private let baseUnits = [("piece", "Stück"), ("gram", "Gramm"), ("milliliter", "Milliliter")]
|
||||||
// Beschriftungen kommen sonst aus DisplaySettings.baseUnitLabel.
|
// Beschriftungen kommen sonst aus DisplaySettings.baseUnitLabel.
|
||||||
@@ -109,7 +117,13 @@ struct ProductFormView: View {
|
|||||||
Section("Artikel") {
|
Section("Artikel") {
|
||||||
LabeledField(label: "Barcode", text: $barcode, keyboard: .numberPad)
|
LabeledField(label: "Barcode", text: $barcode, keyboard: .numberPad)
|
||||||
LabeledField(label: "Name", text: $name)
|
LabeledField(label: "Name", text: $name)
|
||||||
|
if TextScanView.isSupported {
|
||||||
|
Button { scanTarget = .name } label: { Label("Name scannen", systemImage: "text.viewfinder") }
|
||||||
|
}
|
||||||
LabeledField(label: "Marke", text: $brand)
|
LabeledField(label: "Marke", text: $brand)
|
||||||
|
if TextScanView.isSupported {
|
||||||
|
Button { scanTarget = .brand } label: { Label("Marke scannen", systemImage: "text.viewfinder") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
Picker("Typ", selection: $modus) {
|
Picker("Typ", selection: $modus) {
|
||||||
@@ -224,6 +238,13 @@ struct ProductFormView: View {
|
|||||||
CameraPicker(isPresented: $showCamera) { image in pendingImage = image }
|
CameraPicker(isPresented: $showCamera) { image in pendingImage = image }
|
||||||
.ignoresSafeArea()
|
.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
|
.onChange(of: pickerItem) { item in
|
||||||
guard let item else { return }
|
guard let item else { return }
|
||||||
Task {
|
Task {
|
||||||
|
|||||||
Reference in New Issue
Block a user