@@ -1,4 +1,7 @@
import SwiftUI
import Vision
import PhotosUI
import UIKit
struct CheckInView : View {
@ Environment ( \ . dismiss ) private var dismiss
@@ -337,3 +340,431 @@ struct CheckInView: View {
}
}
}
// MARK: - K a s s e n z e t t e l s c a n n e n ( n u r L e b e n s m i t t e l )
private extension UIImage {
// / O r i e n t i e r u n g a u f ` . u p ` n o r m a l i s i e r e n , d a m i t ` c g I m a g e ` - P i x e l u n d ` s i z e ` - P u n k t e
// / z u s a m m e n p a s s e n ( K a m e r a f o t o s s i n d s o n s t g e d r e h t ) .
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 ) )
}
}
// / A u f e i n R e c h t e c k i n P u n k t - K o o r d i n a t e n ( s i z e ) z u s c h n e i d e n .
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 )
}
}
// / O C R ( o n - d e v i c e ) a u f e i n e m A u s s c h n i t t – d e u t s c h e T e x t e r k e n n u n g , z e i l e n w e i s e v o n
// / o b e n n a c h u n t e n . D a s B i l d v e r l ä s s t d a s G e r ä t n i c h t ; n u r d i e T e x t z e i l e n g e h e n z u m
// / S e r v e r f ü r d e n A b g l e i c h .
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 } )
}
}
}
// / O f f e n s i c h t l i c h e N i c h t - A r t i k e l ( P r e i s e , S u m m e n , K o p f / F u ß ) g r o b a u s s o r t i e r e n .
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
}
}
// / M e n g e a u s e i n e r Z e i l e l e s e n ( „ 2 x " , „ 2 S t k " , f ü h r e n d e Z a h l ) , s o n s t 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
}
// / E i n s t i e g : F o t o → B e r e i c h m a r k i e r e n → Z e i l e n p r ü f e n → e i n l a g e r n .
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 : [ ] ) }
}
}
// / R e c h t e c k ü b e r d e n A r t i k e l b l o c k z i e h e n ; l i e f e r t d e n z u g e s c h n i t t e n e n B i l d a u s s c h n i t t .
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 // B i l d s c h i r m - E i n h e i t e n → B i l d p u n k t e
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 }
// / P r ü f e n - L i s t e : j e Z e i l e A r t i k e l ( A u t o - T r e f f e r + e i g e n e S u c h e ) , M e n g e , L a g e r o r t .
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 < KassenzettelZeile > ) -> 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 ) " } ? ? " " )
}
}
// / F r e i e A r t i k e l s u c h e ( n u r L e b e n s m i t t e l ) a l s R ü c k f a l l , w e n n k e i n A u t o - T r e f f e r p a s s t .
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 ) ) } }
}
}
}
}