import SwiftUI import Vision import PhotosUI import UIKit struct CheckInView: View { @Environment(\.dismiss) private var dismiss // Kamera bewusst nur auf Wunsch: „Einlagern“ öffnet ein Formular, kein Sucher. @State private var scanShown = false @State private var paused = false @State private var torchOn = false @State private var torchLocked = false @State private var status: String? @State private var error: String? @State private var product: Product? @State private var scannedItem: Item? @State private var suggestion: LookupResult.Suggestion? @State private var suggestedGroupId: Int? @State private var suggestedGroupName: String? @State private var suggestedCategoryId: Int? @State private var suggestedCategoryName: String? @State private var unknownCode: String? @State private var manualCodeShown = false @State private var manualCode = "" // Manuelle Artikelsuche (statt Scanzwang). @State private var query = "" @State private var results: [Product] = [] @State private var searching = false @State private var units: [Unit] = [] @State private var locations: [StorageLocation] = [] var body: some View { Form { if let status { Section { banner(status, color: .green) } } if let error { Section { banner(error, color: .red) } } Section { Button { error = nil; status = nil scanShown = true } label: { Label("EAN scannen", systemImage: "barcode.viewfinder") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) Button { manualCode = "" manualCodeShown = true } label: { Label("EAN von Hand eingeben", systemImage: "keyboard") } NavigationLink { ProductFormView(prefillBarcode: nil, groupId: nil) { created in product = created } } label: { Label("Neuen Artikel anlegen", systemImage: "plus") } } header: { Text("Neu erfassen") } footer: { Text("Die Kamera öffnet sich erst beim Tippen auf „EAN scannen“ – kein Zwang.") } if let suggestion { suggestionSection(suggestion) } else if let unknownCode { unknownSection(unknownCode) } Section { HStack { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) TextField("Name oder Marke …", text: $query) .autocorrectionDisabled() if !query.isEmpty { Button { query = "" } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) } .buttonStyle(.plain) } } if searching { HStack { ProgressView(); Text("Suche …").foregroundStyle(.secondary) } } ForEach(results) { p in Button { error = nil; status = nil product = p } label: { VStack(alignment: .leading, spacing: 2) { Text(p.name).foregroundStyle(.primary) let unter = [p.brand, "Bestand: \(bestandText(p))"] .compactMap { $0 }.joined(separator: " · ") Text(unter).font(.caption).foregroundStyle(.secondary) } } } if !query.trimmingCharacters(in: .whitespaces).isEmpty && results.isEmpty && !searching { Text("Kein Artikel gefunden – oben neu anlegen.") .foregroundStyle(.secondary).font(.callout) } } header: { Text("Vorhandenen Artikel wählen") } } .navigationTitle("Einlagern") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } } .task { units = (try? await APIClient.shared.units()) ?? [] locations = (try? await APIClient.shared.locations()) ?? [] } .task(id: query) { await search() } .fullScreenCover(isPresented: $scanShown) { scannerCover } .alert("EAN eingeben", isPresented: $manualCodeShown) { TextField("z.B. 8076809572569", text: $manualCode) .keyboardType(.numberPad) Button("Suchen") { let code = manualCode manualCode = "" Task { await resolve(code) } } Button("Abbrechen", role: .cancel) { } } .sheet(item: $product) { item in NavigationStack { if item.isIndividual { // Einzelstücke werden nicht als Charge eingelagert, sondern als // physische Exemplare (UID/QR) angelegt – die zählen den Bestand. ItemAddSheet(productId: item.id, onDone: { status = "Eingelagert\n\(item.name): Einzelstück(e) angelegt." }) } else if item.foodLike { // Lebensmittel + Verbrauchsgegenstand: Charge mit Menge/MHD. CheckInFormView(product: item, units: units, locations: locations) { message in status = message product = nil } } else { // Menge je Lagerort (Gegenstand): ohne MHD, direkt Menge + Lagerort. ObjectAddSheet(product: item, locations: locations, einheit: item.unitName.isEmpty ? "Stück" : item.unitName, perform: { status = "Eingelagert\n\(item.name): Menge hinzugefügt." }) } } } .sheet(item: $scannedItem) { it in NavigationStack { ItemEditView(item: it) } } } // MARK: - Scanner (nur auf Wunsch) private var scannerCover: some View { NavigationStack { ScannerView(onCode: { code in Task { await resolve(code) } }, isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked) .ignoresSafeArea(edges: .bottom) .overlay(alignment: .bottom) { Text("Barcode vor die Kamera halten") .font(.callout) .padding(10) .background(.ultraThinMaterial, in: Capsule()) .padding(.bottom, 24) } .navigationTitle("EAN scannen") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { scanShown = false } } ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) } } } .onAppear { paused = false } } // MARK: - Bausteine private func banner(_ text: String, color: Color) -> some View { // Erste Zeile = Titel (fett), Rest = Detail – ergibt eine saubere, // rundum eingerückte Melde-Pille statt eines randlosen Balkens. let zeilen = text.components(separatedBy: "\n") return VStack(alignment: .leading, spacing: 2) { Text(zeilen.first ?? text).font(.headline) if zeilen.count > 1 { Text(zeilen.dropFirst().joined(separator: "\n")).font(.subheadline) } } .padding(.horizontal, 16).padding(.vertical, 12) .frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(.white) .background(color, in: RoundedRectangle(cornerRadius: 14)) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) .listRowBackground(Color.clear) } private func bestandText(_ p: Product) -> String { let wert = p.stockInArticleUnits let zahl = wert == wert.rounded() ? String(Int(wert)) : String(format: "%.2f", wert) return "\(zahl) \(p.articleUnitLabel)" } /// Sagt vor dem Anlegen, welche Gruppe der Artikel bekommt und warum. @ViewBuilder private func groupNote() -> some View { if let kategorie = suggestedCategoryName { noteRow(symbol: "square.grid.2x2", title: "Wird der Kategorie „\(kategorie)“ zugeordnet", detail: "Aus der Produkt-Datenbank vorgeschlagen – im Formular änderbar.") } if let name = suggestedGroupName { noteRow(symbol: "tag", title: "Wird der Gruppe „\(name)“ zugeordnet", detail: "Dieser EAN-Code ist dort hinterlegt.") } } private func noteRow(symbol: String, title: String, detail: String) -> some View { HStack(alignment: .top, spacing: 6) { Image(systemName: symbol) VStack(alignment: .leading, spacing: 1) { Text(title).font(.caption).bold() Text(detail).font(.caption2).foregroundStyle(.secondary) } } } @ViewBuilder private func suggestionSection(_ item: LookupResult.Suggestion) -> some View { Section { VStack(alignment: .leading, spacing: 8) { Text(item.name).font(.headline) Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · ")) .font(.caption).foregroundStyle(.secondary) Text("In einer Produkt-Datenbank gefunden, noch nicht im Katalog.") .font(.caption2).foregroundStyle(.secondary) groupNote() NavigationLink { ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId, categoryId: suggestedCategoryId, suggestion: item) { created in suggestion = nil product = created } } label: { Label("Anlegen & einlagern", systemImage: "plus.circle.fill") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) } } header: { Text("Vorschlag zum Scan") } } @ViewBuilder private func unknownSection(_ code: String) -> some View { Section { VStack(alignment: .leading, spacing: 8) { Text("Unbekannter Code \(code)").font(.headline) Text("Weder im Katalog noch in Open Food / Products Facts.") .font(.caption).foregroundStyle(.secondary) groupNote() NavigationLink { ProductFormView(prefillBarcode: code, groupId: suggestedGroupId, categoryId: suggestedCategoryId) { created in unknownCode = nil product = created } } label: { Label("Artikel anlegen", systemImage: "plus") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) } } header: { Text("Zum Scan") } } // MARK: - Logik private func search() async { let q = query.trimmingCharacters(in: .whitespaces) guard q.count >= 2 else { results = []; searching = false; return } // Kurze Verzögerung, damit nicht bei jedem Tastendruck abgefragt wird. try? await Task.sleep(nanoseconds: 250_000_000) if Task.isCancelled { return } searching = true defer { searching = false } results = (try? await APIClient.shared.searchProducts(q)) ?? [] } private func resolve(_ code: String) async { paused = true error = nil status = nil suggestion = nil unknownCode = nil do { // Einzelstück-QR (…/i/) → direkt das Stück öffnen statt Barcode-Suche. if let r = code.range(of: "/i/") { let uid = String(code[r.upperBound...]) .trimmingCharacters(in: CharacterSet(charactersIn: "/ ")) .uppercased() if !uid.isEmpty, let item = try? await APIClient.shared.itemByUid(uid: uid) { scannedItem = item scanShown = false return } } let result = try await APIClient.shared.lookup(barcode: code) suggestedGroupId = result.groupId suggestedGroupName = result.groupName suggestedCategoryId = result.categoryId suggestedCategoryName = result.categoryName if let existing = result.existingProduct { product = existing } else if let hint = result.suggestion { suggestion = hint } else { unknownCode = code } scanShown = false } catch { self.error = error.localizedDescription scanShown = false } } } // MARK: - Kassenzettel scannen (nur Lebensmittel) private extension UIImage { /// Orientierung auf `.up` normalisieren, damit `cgImage`-Pixel und `size`-Punkte /// zusammenpassen (Kamerafotos sind sonst gedreht). func nachObenGedreht() -> UIImage { guard imageOrientation != .up else { return self } let format = UIGraphicsImageRendererFormat.default() format.scale = scale return UIGraphicsImageRenderer(size: size, format: format).image { _ in draw(in: CGRect(origin: .zero, size: size)) } } /// Auf ein Rechteck in Punkt-Koordinaten (size) zuschneiden. func zugeschnitten(auf rect: CGRect) -> UIImage? { let px = CGRect(x: rect.minX * scale, y: rect.minY * scale, width: rect.width * scale, height: rect.height * scale) guard let cg = cgImage?.cropping(to: px.integral) else { return nil } return UIImage(cgImage: cg, scale: scale, orientation: .up) } } /// OCR (on-device) auf einem Ausschnitt – deutsche Texterkennung, zeilenweise von /// oben nach unten. Das Bild verlässt das Gerät nicht; nur die Textzeilen gehen zum /// Server für den Abgleich. private func ocrZeilen(_ cg: CGImage) async -> [String] { await withCheckedContinuation { fortsetzung in DispatchQueue.global(qos: .userInitiated).async { let anfrage = VNRecognizeTextRequest() anfrage.recognitionLevel = .accurate anfrage.usesLanguageCorrection = true anfrage.recognitionLanguages = ["de-DE"] try? VNImageRequestHandler(cgImage: cg, options: [:]).perform([anfrage]) let treffer = (anfrage.results as? [VNRecognizedTextObservation]) ?? [] let vonOben = treffer.sorted { $0.boundingBox.maxY > $1.boundingBox.maxY } fortsetzung.resume(returning: vonOben.compactMap { $0.topCandidates(1).first?.string }) } } } /// Offensichtliche Nicht-Artikel (Preise, Summen, Kopf/Fuß) grob aussortieren. private func plausibleArtikelZeilen(_ zeilen: [String]) -> [String] { let muell = ["summe", "gesamt", "zwischensumme", "mwst", "ust", "eur", "bar", "rueckgeld", "rückgeld", "kartenzahlung", "betrag", "total", "kassenbon", "beleg", "datum", "uhr", "filiale", "kunde", "steuer"] return zeilen.compactMap { roh in let s = roh.trimmingCharacters(in: .whitespacesAndNewlines) guard s.filter({ $0.isLetter }).count >= 3 else { return nil } let klein = s.lowercased() return muell.contains(where: { klein.contains($0) }) ? nil : s } } /// Menge aus einer Zeile lesen („2x", „2 Stk", führende Zahl), sonst 1. private func mengeAusZeile(_ zeile: String) -> Double { for muster in ["(\\d+)\\s*[xX]", "(\\d+)\\s*[sS][tT]", "^\\s*(\\d+)\\b"] { if let r = zeile.range(of: muster, options: .regularExpression) { let ziffern = zeile[r].filter { $0.isNumber } if let n = Int(ziffern), n > 0, n < 100 { return Double(n) } } } return 1 } /// Einstieg: Foto → Bereich markieren → Zeilen prüfen → einlagern. struct KassenzettelView: View { @Environment(\.dismiss) private var dismiss @State private var bild: UIImage? @State private var zeilen: [MatchLine]? @State private var analysiere = false @State private var kameraAn = false @State private var galerie: PhotosPickerItem? @State private var locations: [StorageLocation] = [] var body: some View { NavigationStack { inhalt .navigationTitle("Kassenzettel") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Schließen") { dismiss() } } } .task { locations = (try? await APIClient.shared.locations()) ?? [] } .fullScreenCover(isPresented: $kameraAn) { CameraPicker(isPresented: $kameraAn) { img in bild = img.nachObenGedreht(); zeilen = nil } .ignoresSafeArea() } .onChange(of: galerie) { item in guard let item else { return } Task { if let data = try? await item.loadTransferable(type: Data.self), let ui = UIImage(data: data) { bild = ui.nachObenGedreht(); zeilen = nil } galerie = nil } } } } @ViewBuilder private var inhalt: some View { if analysiere { VStack(spacing: 12) { ProgressView(); Text("Lese Artikel…").foregroundStyle(.secondary) } } else if let zeilen { KassenzettelPruefenView(match: zeilen, locations: locations) { dismiss() } } else if let bild { KassenzettelMarkierenView(bild: bild, onWeiter: starteErkennung) { self.bild = nil } } else { start } } private var start: some View { VStack(spacing: 16) { Spacer() Image(systemName: "doc.text.viewfinder").font(.system(size: 52)).foregroundStyle(.secondary) Text("Kassenzettel fotografieren, den Artikelbereich markieren – die App liest die Zeilen und schlägt Lebensmittel vor.") .multilineTextAlignment(.center).foregroundStyle(.secondary).padding(.horizontal) Button { kameraAn = true } label: { Label("Foto aufnehmen", systemImage: "camera").frame(maxWidth: .infinity) }.buttonStyle(.borderedProminent) PhotosPicker(selection: $galerie, matching: .images) { Label("Aus Galerie wählen", systemImage: "photo").frame(maxWidth: .infinity) }.buttonStyle(.bordered) Spacer() }.padding() } private func starteErkennung(_ ausschnitt: UIImage) { analysiere = true Task { let erg = await erkenne(ausschnitt) await MainActor.run { zeilen = erg; analysiere = false } } } private func erkenne(_ img: UIImage) async -> [MatchLine] { guard let cg = img.cgImage else { return [] } let roh = await ocrZeilen(cg) let zeilen = plausibleArtikelZeilen(roh) guard !zeilen.isEmpty else { return [] } return (try? await APIClient.shared.matchReceipt(zeilen)) ?? zeilen.map { MatchLine(text: $0, candidates: []) } } } /// Rechteck über den Artikelblock ziehen; liefert den zugeschnittenen Bildausschnitt. struct KassenzettelMarkierenView: View { let bild: UIImage var onWeiter: (UIImage) -> Void var onNeu: () -> Void @State private var rect: CGRect = .zero @State private var startRect: CGRect? @State private var fitRect: CGRect = .zero var body: some View { VStack(spacing: 12) { Text("Ziehe das Rechteck über die Artikel. Am Griff unten rechts vergrößern.") .font(.callout).foregroundStyle(.secondary) .multilineTextAlignment(.center).padding(.horizontal) GeometryReader { geo in ZStack(alignment: .topLeading) { Image(uiImage: bild).resizable().scaledToFit() .frame(width: geo.size.width, height: geo.size.height) Rectangle().stroke(Color.accentColor, lineWidth: 2) .background(Color.accentColor.opacity(0.15)) .frame(width: rect.width, height: rect.height) .offset(x: rect.minX, y: rect.minY) .gesture(zieh(resize: false)) Rectangle().fill(Color.accentColor) .frame(width: 26, height: 26) .offset(x: rect.maxX - 13, y: rect.maxY - 13) .gesture(zieh(resize: true)) } .onAppear { setzeFit(geo.size) } .onChange(of: geo.size) { setzeFit($0) } } HStack { Button("Neues Foto", action: onNeu) Spacer() Button("Weiter") { weiter() }.buttonStyle(.borderedProminent) }.padding(.horizontal) }.padding(.vertical) } private func setzeFit(_ container: CGSize) { let iw = bild.size.width, ih = bild.size.height guard iw > 0, ih > 0, container.width > 0 else { return } let s = min(container.width / iw, container.height / ih) let w = iw * s, h = ih * s fitRect = CGRect(x: (container.width - w) / 2, y: (container.height - h) / 2, width: w, height: h) if rect == .zero { rect = fitRect.insetBy(dx: fitRect.width * 0.08, dy: fitRect.height * 0.12) } } private func zieh(resize: Bool) -> some Gesture { DragGesture() .onChanged { v in if startRect == nil { startRect = rect } guard let s = startRect else { return } var r = s if resize { r.size.width = max(48, s.width + v.translation.width) r.size.height = max(48, s.height + v.translation.height) } else { r.origin.x = s.origin.x + v.translation.width r.origin.y = s.origin.y + v.translation.height } rect = klemme(r) } .onEnded { _ in startRect = nil } } private func klemme(_ r: CGRect) -> CGRect { guard fitRect.width > 0 else { return r } var o = r o.size.width = min(o.size.width, fitRect.width) o.size.height = min(o.size.height, fitRect.height) o.origin.x = min(max(o.origin.x, fitRect.minX), fitRect.maxX - o.width) o.origin.y = min(max(o.origin.y, fitRect.minY), fitRect.maxY - o.height) return o } private func weiter() { guard fitRect.width > 0 else { onWeiter(bild); return } let s = bild.size.width / fitRect.width // Bildschirm-Einheiten → Bildpunkte let crop = CGRect(x: (rect.minX - fitRect.minX) * s, y: (rect.minY - fitRect.minY) * s, width: rect.width * s, height: rect.height * s) onWeiter(bild.zugeschnitten(auf: crop) ?? bild) } } private struct KassenzettelArtikel: Hashable { let id: Int; let name: String; let unit: String } private struct KassenzettelZeile: Identifiable { let id = UUID() let text: String let kandidaten: [MatchCandidate] var artikel: KassenzettelArtikel? var menge: Double var ortId: String? } private struct ZeilenRef: Identifiable { let id: UUID } /// Prüfen-Liste: je Zeile Artikel (Auto-Treffer + eigene Suche), Menge, Lagerort. struct KassenzettelPruefenView: View { let match: [MatchLine] let locations: [StorageLocation] var onFertig: () -> Void @State private var zeilen: [KassenzettelZeile] = [] @State private var sucheZiel: ZeilenRef? @State private var scanZiel: ZeilenRef? @State private var busy = false @State private var meldung: String? private var zugeordnet: Int { zeilen.filter { $0.artikel != nil }.count } var body: some View { Group { if zeilen.isEmpty { VStack(spacing: 10) { Image(systemName: "doc.text.magnifyingglass").font(.system(size: 40)).foregroundStyle(.secondary) Text("Keine Artikel erkannt.").font(.headline) Text("Versuch es mit einem engeren Rechteck oder besserem Licht.") .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) }.padding() } else { List { ForEach($zeilen) { $z in zeile($z) } Section { Button { Task { await einlagern() } } label: { HStack { if busy { ProgressView().padding(.trailing, 4) } Text("\(zugeordnet) Artikel einlagern") } }.disabled(busy || zugeordnet == 0) } footer: { Text("Nicht zugeordnete Zeilen werden übersprungen. Neue Artikel werden nicht angelegt.") } } } } .onAppear(perform: aufbauen) .sheet(item: $sucheZiel) { ref in NavigationStack { ArtikelSucheSheet { produkt in setzeArtikel(ref.id, produkt: produkt); sucheZiel = nil } } } .sheet(item: $scanZiel) { ref in LocationScannerView(locations: locations) { loc in setzeOrt(ref.id, loc.id); scanZiel = nil } } .alert("Fertig", isPresented: Binding(get: { meldung != nil }, set: { if !$0 { meldung = nil; onFertig() } })) { Button("OK") { } } message: { Text(meldung ?? "") } } @ViewBuilder private func zeile(_ z: Binding) -> some View { let zeile = z.wrappedValue VStack(alignment: .leading, spacing: 8) { Text(zeile.text).font(.caption).foregroundStyle(.secondary).lineLimit(1) Menu { ForEach(zeile.kandidaten) { k in Button("\(k.name) · \(k.score) %") { z.wrappedValue.artikel = KassenzettelArtikel(id: k.productId, name: k.name, unit: k.checkInUnit) } } Divider() Button { sucheZiel = ZeilenRef(id: zeile.id) } label: { Label("Suchen…", systemImage: "magnifyingglass") } if zeile.artikel != nil { Button(role: .destructive) { z.wrappedValue.artikel = nil } label: { Label("Nicht zuordnen", systemImage: "xmark") } } } label: { HStack { Image(systemName: zeile.artikel == nil ? "questionmark.circle" : "checkmark.circle.fill") .foregroundStyle(zeile.artikel == nil ? Color.secondary : Color.green) Text(zeile.artikel?.name ?? "– Artikel wählen –") .foregroundStyle(zeile.artikel == nil ? Color.secondary : Color.primary) Spacer() Image(systemName: "chevron.up.chevron.down").font(.caption2).foregroundStyle(.secondary) } } HStack(spacing: 12) { Stepper("Menge \(Int(zeile.menge))", value: z.menge, in: 1...99).fixedSize() Spacer() Menu { Button("– ohne –") { z.wrappedValue.ortId = nil } ForEach(locations) { loc in Button(loc.name) { z.wrappedValue.ortId = loc.id } } } label: { Label(ortName(zeile.ortId), systemImage: "mappin.and.ellipse").font(.callout).lineLimit(1) } Button { scanZiel = ZeilenRef(id: zeile.id) } label: { Image(systemName: "qrcode.viewfinder") }.buttonStyle(.borderless) } }.padding(.vertical, 4) } private func ortName(_ id: String?) -> String { guard let id else { return "ohne Ort" } return locations.first { $0.id == id }?.name ?? "Ort" } private func aufbauen() { guard zeilen.isEmpty else { return } zeilen = match.map { m in KassenzettelZeile( text: m.text, kandidaten: m.candidates, artikel: m.candidates.first.map { KassenzettelArtikel(id: $0.productId, name: $0.name, unit: $0.checkInUnit) }, menge: mengeAusZeile(m.text), ortId: nil, ) } } private func setzeArtikel(_ id: UUID, produkt: Product) { guard let i = zeilen.firstIndex(where: { $0.id == id }) else { return } let unit = (produkt.packageSize ?? 0) > 0 ? "package" : produkt.baseUnit zeilen[i].artikel = KassenzettelArtikel(id: produkt.id, name: produkt.name, unit: unit) } private func setzeOrt(_ id: UUID, _ ortId: String) { guard let i = zeilen.firstIndex(where: { $0.id == id }) else { return } zeilen[i].ortId = ortId } private func einlagern() async { busy = true var ein = 0, weg = 0 var fehler: String? for z in zeilen { guard let a = z.artikel else { weg += 1; continue } let payload = BatchCheckInRequest( productId: a.id, unit: a.unit, lines: [CheckInLine(quantity: z.menge, bestBefore: nil, bestBeforePrecision: "day", locationId: z.ortId)], ) do { _ = try await APIClient.shared.checkInBatch(payload); ein += 1 } catch { fehler = error.localizedDescription } } busy = false meldung = "\(ein) eingelagert, \(weg) übersprungen." + (fehler.map { " Fehler: \($0)" } ?? "") } } /// Freie Artikelsuche (nur Lebensmittel) als Rückfall, wenn kein Auto-Treffer passt. struct ArtikelSucheSheet: View { var onPick: (Product) -> Void @Environment(\.dismiss) private var dismiss @State private var text = "" @State private var treffer: [Product] = [] @State private var suchlauf = UUID() var body: some View { List(treffer) { p in Button { onPick(p); dismiss() } label: { VStack(alignment: .leading, spacing: 2) { Text(p.name).foregroundStyle(.primary) if let b = p.brand, !b.isEmpty { Text(b).font(.caption).foregroundStyle(.secondary) } } } } .listStyle(.plain) .searchable(text: $text, prompt: "Lebensmittel suchen") .navigationTitle("Artikel suchen") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } } .onChange(of: text) { q in let lauf = UUID(); suchlauf = lauf Task { try? await Task.sleep(nanoseconds: 250_000_000) if suchlauf != lauf { return } let liste = (try? await APIClient.shared.searchProducts(q)) ?? [] let food = liste.filter { $0.tracking != "object" } await MainActor.run { if suchlauf == lauf { treffer = Array(food.prefix(25)) } } } } } }