2026-03-20 02:06:19 -05:00
import SwiftUI
import VisionKit
2026-03-22 01:35:53 -05:00
import Vision
2026-03-20 02:06:19 -05:00
import LabWiseKit
2026-03-22 01:35:53 -05:00
import FoundationModels
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
// MARK: - O n - d e v i c e A I l a b e l e x t r a c t i o n
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
@ Generable
struct LabelExtraction {
@ Guide ( description : " The product or chemical name as it appears on the label, e.g. 'Neutralization/Binding Buffer (S3)' or 'Sodium Chloride'. Empty string if not found. " )
var chemicalName : String
@ Guide ( description : " Physical state: 'Solid', 'Liquid', or 'Gas'. Infer from context clues like 'solution', 'buffer', 'powder', 'crystals'. Empty string if unknown. " )
var physicalState : String
@ Guide ( description : " Vendor or manufacturer name, e.g. 'Sigma-Aldrich', 'Fisher Scientific'. Empty string if not found. " )
var vendor : String
@ Guide ( description : " CAS registry number in the format XXXXXXX-XX-X, e.g. '7647-14-5'. Empty string if not found. " )
var casNumber : String
@ Guide ( description : " Lot number or batch number, e.g. 'SLBH4570V', '123456'. Usually preceded by 'Lot', 'Lot#', or 'Batch'. Empty string if not found. " )
var lotNumber : String
@ Guide ( description : " Expiration or use-by date found explicitly in the label text. Normalize to YYYY-MM-DD if possible, otherwise return as-is. Return empty string if no expiration keyword and date are present in the text — do not guess. " )
var expirationDate : String
@ Guide ( description : " The amount or quantity of contents, as a number only, e.g. '5', '100', '2.5'. Empty string if not found. " )
var amount : String
@ Guide ( description : " The unit for the amount, e.g. 'g', 'mL', 'kg', 'mg', 'L'. Empty string if not found. " )
var unit : String
@ Guide ( description : " Catalog or product number, e.g. 'P0662-500G', 'S7653'. Empty string if not found. " )
var catalogNumber : String
@ Guide ( description : " Your confidence in the chemical name extraction, from 0 to 100. Use 90+ only when the name is explicitly printed as a clear product/chemical name. Use 50-89 when inferred. Use below 50 if the text is ambiguous or noisy. " , . range ( 0. . . 100 ) )
var nameConfidence : Int
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
private let labelExtractionInstructions = Instructions {
" " "
You are a laboratory chemical label parser . Given raw OCR text lines from a reagent bottle label , \
extract only the fields that are explicitly present in the text . \
Do NOT invent , infer , or hallucinate values — if a field is not clearly visible in the provided text , return an empty string for it . \
For expiration dates : look for keywords like EXP , EXPIRY , EXPIRATION , USE BY , BEST BEFORE followed by a date . \
For amount and unit : look for a number directly followed by a unit like g , gm , mg , kg , mL , L , mol . \
Set nameConfidence honestly : 90 + only when the product name is explicitly and clearly printed ; 50 - 89 when inferred ; below 50 when guessing .
" " "
}
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
private let aiNameConfidenceThreshold = 60 // m i n i m u m c o n f i d e n c e t o a p p l y A I - e x t r a c t e d n a m e
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
// MARK: - N a m e a r b i t r a t i o n
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
@ Generable ( description : " Result of comparing a chemical's IUPAC/systematic name against its label name " )
struct NameArbitration {
@ Guide ( description : " True if the labelName is a common name, abbreviation, or trade name that refers to the same compound as iupacName. False if they seem unrelated. " )
var isSameCompound : Bool
@ Guide ( description : " True if labelName is a common/abbreviated name and iupacName is the corresponding systematic/IUPAC name for it. False if labelName is also a systematic or verbose name. " )
var labelIsCommonName : Bool
@ Guide ( description : " The name to display: if labelIsCommonName is true use the labelName, otherwise use the iupacName. " )
var preferredName : String
}
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
private let nameArbitrationInstructions = Instructions {
" " "
You are a chemistry expert . You will be given two names for a chemical compound : \
an IUPAC / systematic name from a database , and a name read from a product label . \
Determine if they refer to the same compound , and whether the label name is a \
common name or abbreviation ( like ' CHAPS ' , ' HEPES ' , ' SDS ' , ' Ethanol ' ) versus a \
systematic / verbose name . Prefer the common name when it is clearly an established \
abbreviation or trade name used in laboratory settings .
" " "
}
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
@ available ( iOS 18.1 , * )
private func arbitrateNames ( iupacName : String , labelName : String ) async -> NameArbitration ? {
let model = SystemLanguageModel . default
guard case . available = model . availability else { return nil }
let session = LanguageModelSession ( instructions : nameArbitrationInstructions )
let prompt = " IUPAC name: \( iupacName ) \n Label name: \( labelName ) "
return try ? await session . respond ( to : prompt , generating : NameArbitration . self ) . content
}
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
// / S t r i n g s t h e m o d e l e m i t s w h e n i t h a s n o t h i n g r e a l t o r e t u r n .
private let aiNullishValues : Set < String > = [
" " , " n/a " , " na " , " none " , " null " , " unknown " , " not found " , " not available " ,
" not specified " , " not present " , " not visible " , " not stated " , " not given " ,
" missing " , " empty " , " - " , " — " , " ? " , " . " , " 0 " , " 00 " , " 000 "
]
// / R e t u r n s n i l i f t h e s t r i n g i s a m o d e l - g e n e r a t e d p l a c e h o l d e r r a t h e r t h a n r e a l d a t a .
private func aiSanitize ( _ value : String ) -> String {
let lowered = value . trimmingCharacters ( in : . whitespaces ) . lowercased ( )
if aiNullishValues . contains ( lowered ) { return " " }
return value . trimmingCharacters ( in : . whitespaces )
}
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
// / V a l i d a t e s a n d s a n i t i z e s a d a t e s t r i n g . R e t u r n s e m p t y s t r i n g i f i t d o e s n ' t
// / l o o k l i k e a r e a l d a t e ( e . g . " 0 0 0 0 - 0 0 - 0 0 " , " 1 9 0 0 - 0 1 - 0 1 " , " 9 9 9 9 - 9 9 - 9 9 " ) .
private func aiSanitizeDate ( _ value : String ) -> String {
let s = aiSanitize ( value )
guard ! s . isEmpty else { return " " }
// R e j e c t z e r o / p l a c e h o l d e r d a t e s
let stripped = s . replacingOccurrences ( of : " - " , with : " " )
. replacingOccurrences ( of : " / " , with : " " )
. replacingOccurrences ( of : " 0 " , with : " " )
if stripped . isEmpty { return " " } // a l l z e r o s a f t e r s t r i p p i n g s e p a r a t o r s
// M u s t c o n t a i n a p l a u s i b l e 4 - d i g i t y e a r ( 1 9 0 0 – 2 0 9 9 )
let yearRegex = try ? NSRegularExpression ( pattern : # " (19|20) \ d{2} " # )
let range = NSRange ( s . startIndex . . . , in : s )
guard yearRegex ? . firstMatch ( in : s , range : range ) != nil else { return " " }
return s
}
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
// / V a l i d a t e s a C A S n u m b e r — m u s t m a t c h t h e X X X X X X X - X X - X p a t t e r n .
private func aiSanitizeCAS ( _ value : String ) -> String {
let s = aiSanitize ( value )
guard ! s . isEmpty else { return " " }
let range = NSRange ( s . startIndex . . . , in : s )
guard ( try ? NSRegularExpression ( pattern : # " ^ \ d{2,7}- \ d{2}- \ d$ " # ) ) ? . firstMatch ( in : s , range : range ) != nil
else { return " " }
return s
}
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
// / V a l i d a t e s a n u m e r i c a m o u n t — m u s t b e a p o s i t i v e n u m b e r .
private func aiSanitizeAmount ( _ value : String ) -> String {
let s = aiSanitize ( value )
guard ! s . isEmpty , let d = Double ( s ) , d > 0 else { return " " }
return s
}
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
// / S a n i t i z e s a u n i t s t r i n g — m u s t b e a k n o w n l a b u n i t , n o t a f i l l e r w o r d .
private let knownUnits : Set < String > = [
" g " , " gm " , " mg " , " kg " , " ml " , " ml " , " l " , " mmol " , " mol " , " ul " , " µl " , " oz " , " lb "
]
private func aiSanitizeUnit ( _ value : String ) -> String {
let s = aiSanitize ( value )
guard ! s . isEmpty else { return " " }
return knownUnits . contains ( s . lowercased ( ) ) ? s : " "
}
2026-03-22 02:36:24 -05:00
// MARK: - C o n t a i n e r t y p e o p t i o n s
2026-03-22 01:35:53 -05:00
2026-03-22 02:36:24 -05:00
// / A l l c o n t a i n e r t y p e s a v a i l a b l e f o r t h e u s e r t o s e l e c t .
let containerTypeOptions : [ String ] = [
" Glass Bottle " , " Plastic Bottle " , " Vial " , " Ampoule " , " Flask " ,
" Beaker " , " Tube " , " Jar " , " Can " , " Canister " , " Drum " , " Bag "
]
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
@ available ( iOS 18.1 , * )
private func onDeviceLabelLookup ( ocrLines : [ String ] ) async -> LabelExtraction ? {
let model = SystemLanguageModel . default
guard case . available = model . availability else { return nil }
let session = LanguageModelSession ( instructions : labelExtractionInstructions )
let joined = ocrLines . joined ( separator : " \n " )
let prompt = " Label text: \n \( joined ) "
guard var result = try ? await session . respond ( to : prompt , generating : LabelExtraction . self ) . content
else { return nil }
// S a n i t i z e e v e r y f i e l d s o c a l l e r s n e v e r r e c e i v e p l a c e h o l d e r s l i k e " N / A " , " 0 0 0 0 - 0 0 - 0 0 " , e t c .
result . chemicalName = aiSanitize ( result . chemicalName )
result . physicalState = aiSanitize ( result . physicalState )
result . vendor = aiSanitize ( result . vendor )
result . casNumber = aiSanitizeCAS ( result . casNumber )
result . lotNumber = aiSanitize ( result . lotNumber )
result . expirationDate = aiSanitizeDate ( result . expirationDate )
result . amount = aiSanitizeAmount ( result . amount )
result . unit = aiSanitizeUnit ( result . unit )
result . catalogNumber = aiSanitize ( result . catalogNumber )
return result
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
// MARK: - S c a n n e d i t e m ( o n e b o t t l e ' s w o r t h o f d a t a )
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
struct ScannedItem : Identifiable {
let id = UUID ( )
2026-03-20 02:06:19 -05:00
var chemicalName = " "
2026-03-22 01:35:53 -05:00
var casNumber = " "
2026-03-20 02:06:19 -05:00
var chemicalFormula = " "
var molecularWeight = " "
var vendor = " "
var catalogNumber = " "
var lotNumber = " "
var concentration = " "
var expirationDate = " "
var physicalState = " "
2026-03-22 01:35:53 -05:00
var storageDevice = " Glass Bottle "
2026-03-20 02:06:19 -05:00
var amountPerContainer = " "
var unitOfMeasure = " "
2026-03-22 01:35:53 -05:00
var barcode : String ?
2026-03-20 02:06:19 -05:00
var percentageFull : Double = 50
2026-03-22 01:35:53 -05:00
var thumbnail : UIImage ?
}
// MARK: - C o n f i d e n c e a c c u m u l a t o r
// / T r a c k s h o w m a n y t i m e s a g i v e n v a l u e h a s b e e n s e e n f o r a f i e l d .
// / A f i e l d " l o c k s i n " o n c e t h e s a m e v a l u e i s s e e n ` t h r e s h o l d ` t i m e s .
private final class FieldAccumulator {
private var votes : [ String : Int ] = [ : ]
private let threshold : Int
init ( threshold : Int = 2 ) {
self . threshold = threshold
}
// / R e g i s t e r a n e w o b s e r v a t i o n . R e t u r n s t h e w i n n i n g v a l u e i f i t
// / h a s n o w r e a c h e d t h e t h r e s h o l d , o t h e r w i s e n i l .
func observe ( _ value : String ) -> String ? {
guard ! value . trimmingCharacters ( in : . whitespaces ) . isEmpty else { return nil }
votes [ value , default : 0 ] += 1
if let ( winner , count ) = votes . max ( by : { $0 . value < $1 . value } ) , count >= threshold {
return winner
}
return nil
}
func reset ( ) { votes = [ : ] }
// / B e s t g u e s s e v e n b e f o r e t h r e s h o l d ( f o r l i v e p r e v i e w ) .
var bestGuess : String ? {
votes . max ( by : { $0 . value < $1 . value } ) ? . key
}
}
// MARK: - P u b C h e m l o o k u p
private struct PubChemResult {
var iupacName : String ? // r a w I U P A C / p r e f e r r e d n a m e f r o m p r o p s
var commonName : String ? // b e s t s h o r t s y n o n y m ( e . g . " C H A P S " , " H E P E S " )
var chemicalFormula : String ?
var molecularWeight : String ?
var physicalState : String ?
// / T h e n a m e t o a c t u a l l y d i s p l a y : c o m m o n n a m e i f o n e w a s f o u n d , o t h e r w i s e I U P A C .
var bestName : String ? { commonName ? ? iupacName }
}
// / P a t t e r n s t h a t i n d i c a t e a s y n o n y m i s a d a t a b a s e I D r a t h e r t h a n a u s a b l e n a m e .
private let synonymJunkPatterns : [ NSRegularExpression ] = [
try ! NSRegularExpression ( pattern : # " ^ \ d{2,7}- \ d{2}- \ d$ " # ) , // C A S n u m b e r
try ! NSRegularExpression ( pattern : # " ^[A-Z]{2,}[: \ -_] \ d " # ) , // D T X S I D 6 … , C H E B I : …
try ! NSRegularExpression ( pattern : # " ^[A-Z0-9]{8,}$ " # ) , // I n C h I K e y - s t y l e a l l - c a p s
try ! NSRegularExpression ( pattern : # " (?i)InChI= " # ) , // I n C h I s t r i n g s
]
private func isJunkSynonym ( _ s : String ) -> Bool {
let range = NSRange ( s . startIndex . . . , in : s )
return synonymJunkPatterns . contains { $0 . firstMatch ( in : s , range : range ) != nil }
}
// / R e t u r n s t h e b e s t h u m a n - r e a d a b l e s y n o n y m : s h o r t e s t c l e a n n a m e t h a t i s n ' t t h e I U P A C n a m e i t s e l f .
// / P r e f e r s n a m e s ≤ 3 0 c h a r a c t e r s ( a b b r e v i a t i o n s / c o m m o n n a m e s ) b u t w i l l t a k e u p t o 6 0 i f n o t h i n g s h o r t e r e x i s t s .
private func bestSynonym ( from synonyms : [ String ] , iupacName : String ? ) -> String ? {
let iupacLower = iupacName ? . lowercased ( ) ? ? " "
let candidates = synonyms . filter { s in
! isJunkSynonym ( s ) && s . lowercased ( ) != iupacLower && s . count <= 60
}
// P r e f e r s h o r t n a m e s f i r s t ( a b b r e v i a t i o n s l i k e C H A P S , H E P E S , S D S )
if let short = candidates . filter ( { $0 . count <= 30 } ) . min ( by : { $0 . count < $1 . count } ) {
return short
}
return candidates . min ( by : { $0 . count < $1 . count } )
}
private func pubChemLookup ( cas : String ) async -> PubChemResult ? {
guard ! cas . isEmpty ,
let encoded = cas . addingPercentEncoding ( withAllowedCharacters : . urlPathAllowed ) ,
let url = URL ( string : " https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/ \( encoded ) /JSON " )
else { return nil }
guard let ( data , _ ) = try ? await URLSession . shared . data ( from : url ) ,
let json = try ? JSONSerialization . jsonObject ( with : data ) as ? [ String : Any ] ,
let compounds = ( json [ " PC_Compounds " ] as ? [ [ String : Any ] ] ) ? . first
else { return nil }
var result = PubChemResult ( )
// E x t r a c t C I D f o r s y n o n y m l o o k u p , p l u s f o r m u l a / w e i g h t / s t a t e / I U P A C n a m e
var cid : Int ?
if let cidVal = compounds [ " id " ] as ? [ String : Any ] ,
let cidCompound = cidVal [ " id " ] as ? [ String : Any ] ,
let cidInt = cidCompound [ " cid " ] as ? Int {
cid = cidInt
}
if let props = compounds [ " props " ] as ? [ [ String : Any ] ] {
for prop in props {
guard let urn = prop [ " urn " ] as ? [ String : Any ] ,
let value = prop [ " value " ] as ? [ String : Any ] else { continue }
let label = urn [ " label " ] as ? String ? ? " "
let name = urn [ " name " ] as ? String ? ? " "
if label = = " IUPAC Name " , name = = " Preferred " {
result . iupacName = value [ " sval " ] as ? String
}
if label = = " Molecular Formula " {
result . chemicalFormula = value [ " sval " ] as ? String
}
if label = = " Molecular Weight " {
if let mw = value [ " fval " ] { result . molecularWeight = " \( mw ) " }
else if let mw = value [ " sval " ] as ? String { result . molecularWeight = mw }
}
if label = = " Physical Description " {
let desc = ( value [ " sval " ] as ? String ? ? " " ) . lowercased ( )
if desc . contains ( " liquid " ) { result . physicalState = " Liquid " }
else if desc . contains ( " solid " ) || desc . contains ( " powder " ) || desc . contains ( " crystal " ) {
result . physicalState = " Solid "
} else if desc . contains ( " gas " ) { result . physicalState = " Gas " }
}
}
}
// F e t c h s y n o n y m s t o f i n d c o m m o n / t r a d e n a m e ( e . g . " C H A P S " i n s t e a d o f t h e l o n g I U P A C s t r i n g )
if let cid {
if let synURL = URL ( string : " https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/ \( cid ) /synonyms/JSON " ) ,
let ( synData , _ ) = try ? await URLSession . shared . data ( from : synURL ) ,
let synJSON = try ? JSONSerialization . jsonObject ( with : synData ) as ? [ String : Any ] ,
let infoList = ( synJSON [ " InformationList " ] as ? [ String : Any ] ) ? [ " Information " ] as ? [ [ String : Any ] ] ,
let synonyms = infoList . first ? [ " Synonym " ] as ? [ String ] {
result . commonName = bestSynonym ( from : synonyms , iupacName : result . iupacName )
}
}
return result
}
// MARK: - R e g e x e x t r a c t o r s
private let casRegex = try ! NSRegularExpression ( pattern : # " \ b( \ d{2,7}- \ d{2}- \ d) \ b " # )
private let lotRegex = try ! NSRegularExpression ( pattern : # " (?i)(?:lot|lot \ s*#|lot \ s*no \ .?) \ s*[: \ s]? \ s*([A-Z0-9]{4,}) " # )
private let mwRegex = try ! NSRegularExpression ( pattern : # " (?i)(?:M \ .?W \ .?|MW|Mol \ .? \ s*Wt \ .?) \ s*[: \ s]? \ s*([ \ d.,]+) " # )
private let sizeRegex = try ! NSRegularExpression ( pattern : # " \ b( \ d+(?: \ . \ d+)?) \ s*(mL|ml|L|g|gm|kg|mg|oz|lb|gal|mol|mmol|µL|uL) \ b " # , options : . caseInsensitive )
private let expRegex = try ! NSRegularExpression ( pattern : # " (?i)(?:exp(?:iry|iration)? \ .? \ s*|use \ s*by \ s*|best \ s*before \ s*)( \ d{1,2}[/ \ -] \ d{4}| \ d{4}[/ \ -] \ d{2}(?:[/ \ -] \ d{2})?|(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z.]* \ s+ \ d{4}| \ d{4}) " # )
private let knownVendors = [ " Sigma-Aldrich " , " Sigma Aldrich " , " Aldrich " , " Millipore Sigma " ,
" VWR " , " Fisher " , " Fisher Scientific " , " Thermo Fisher " ,
" Acros " , " Acros Organics " , " BDH " , " Amresco " , " EMD " ,
" Alfa Aesar " , " TCI " , " Fluka " , " J.T. Baker " ]
private func extract ( from lines : [ String ] ) -> [ String : String ] {
let full = lines . joined ( separator : " \n " )
var fields : [ String : String ] = [ : ]
func first ( regex : NSRegularExpression , in text : String , group : Int = 1 ) -> String ? {
let range = NSRange ( text . startIndex . . . , in : text )
guard let m = regex . firstMatch ( in : text , range : range ) ,
let r = Range ( m . range ( at : group ) , in : text ) else { return nil }
return String ( text [ r ] ) . trimmingCharacters ( in : . whitespaces )
}
if let cas = first ( regex : casRegex , in : full ) { fields [ " cas " ] = cas }
if let lot = first ( regex : lotRegex , in : full ) { fields [ " lot " ] = lot }
if let mw = first ( regex : mwRegex , in : full ) { fields [ " mw " ] = mw }
if let exp = first ( regex : expRegex , in : full ) { fields [ " exp " ] = normalizeDate ( exp ) }
if let m = sizeRegex . firstMatch ( in : full , range : NSRange ( full . startIndex . . . , in : full ) ) ,
let valRange = Range ( m . range ( at : 1 ) , in : full ) ,
let unitRange = Range ( m . range ( at : 2 ) , in : full ) {
fields [ " amount " ] = String ( full [ valRange ] )
fields [ " unit " ] = String ( full [ unitRange ] )
}
for vendor in knownVendors {
if full . localizedCaseInsensitiveContains ( vendor ) {
fields [ " vendor " ] = vendor
break
}
}
// C a t a l o g n u m b e r : l i n e m a t c h i n g v e n d o r c a t a l o g p a t t e r n s l i k e " P 0 6 6 2 - 5 0 0 G " , " B D H 9 2 5 8 - 5 0 0 G "
for line in lines {
let trimmed = line . trimmingCharacters ( in : . whitespaces )
if trimmed . range ( of : # " ^[A-Z]{1,4} \ d{3,}- \ d+[A-Z]*$ " # , options : . regularExpression ) != nil {
fields [ " catalog " ] = trimmed
break
}
}
return fields
}
private let monthNames : [ String : Int ] = [
" jan " : 1 , " feb " : 2 , " mar " : 3 , " apr " : 4 , " may " : 5 , " jun " : 6 ,
" jul " : 7 , " aug " : 8 , " sep " : 9 , " oct " : 10 , " nov " : 11 , " dec " : 12
]
private func normalizeDate ( _ raw : String ) -> String {
let trimmed = raw . trimmingCharacters ( in : . whitespaces )
// M o n t h - n a m e f o r m a t s : " J u n . 2 0 1 8 " , " J u n e 2 0 1 8 " , " j u n 2 0 1 8 "
let monthNameRegex = try ? NSRegularExpression ( pattern : # " (?i)^([a-z]{3})[a-z.]* \ s+( \ d{4})$ " # )
if let m = monthNameRegex ? . firstMatch ( in : trimmed , range : NSRange ( trimmed . startIndex . . . , in : trimmed ) ) ,
let mRange = Range ( m . range ( at : 1 ) , in : trimmed ) ,
let yRange = Range ( m . range ( at : 2 ) , in : trimmed ) ,
let month = monthNames [ String ( trimmed [ mRange ] ) . lowercased ( ) ] ,
let year = Int ( trimmed [ yRange ] ) {
return String ( format : " %04d-%02d-01 " , year , month )
}
// N u m e r i c f o r m a t s : M M / Y Y Y Y , M M - Y Y Y Y , Y Y Y Y / M M , Y Y Y Y - M M , Y Y Y Y - M M - D D
let parts = trimmed . components ( separatedBy : CharacterSet ( charactersIn : " /- " ) )
if parts . count = = 2 {
if parts [ 0 ] . count <= 2 , let month = Int ( parts [ 0 ] ) , let year = Int ( parts [ 1 ] ) , year > 2000 {
return String ( format : " %04d-%02d-01 " , year , month )
}
if parts [ 0 ] . count = = 4 , let year = Int ( parts [ 0 ] ) , let month = Int ( parts [ 1 ] ) {
return String ( format : " %04d-%02d-01 " , year , month )
}
}
if parts . count = = 3 , parts [ 0 ] . count = = 4 {
return " \( parts [ 0 ] ) - \( parts [ 1 ] . padLeft ( 2 ) ) - \( parts [ 2 ] . padLeft ( 2 ) ) "
}
return trimmed
}
private extension String {
func padLeft ( _ length : Int ) -> String {
count >= length ? self : String ( repeating : " 0 " , count : length - count ) + self
}
}
// MARK: - S c a n n e r v i e w m o d e l
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
@ Observable
@ MainActor
final class LabelScannerViewModel {
// S e s s i o n s t a t e
var isScanning = false // l i v e s c a n n i n g s e s s i o n a c t i v e
var scannedItems : [ ScannedItem ] = [ ]
var showReview = false
// C u r r e n t i t e m b e i n g b u i l t
var current = ScannedItem ( )
var isLookingUpPubChem = false
// L i v e p r e v i e w f i e l d s ( b e s t - g u e s s , u p d a t e s e v e r y f r a m e )
var previewFields : [ String : String ] = [ : ]
// P r o f i l e ( p r e - f i l l e d o n l o c a t i o n f i e l d s )
2026-03-20 02:06:19 -05:00
var piFirstName = " "
var bldgCode = " "
var lab = " "
var storageLocation = " "
var contact = " "
2026-03-22 01:35:53 -05:00
// A c c u m u l a t o r s f o r c u r r e n t i t e m .
// C A S u s e s a h i g h e r t h r e s h o l d — a p a r t i a l O C R r e a d ( e . g . m i s s i n g l a s t d i g i t ) l o o k s v a l i d
// t o t h e r e g e x b u t i s w r o n g . M o r e v o t e s = m o r e c o n f i d e n c e b e f o r e c o m m i t t i n g .
private var accumulators : [ String : FieldAccumulator ] = [
" cas " : FieldAccumulator ( threshold : 3 ) ,
" lot " : FieldAccumulator ( threshold : 2 ) ,
" mw " : FieldAccumulator ( threshold : 2 ) ,
" exp " : FieldAccumulator ( threshold : 2 ) ,
" amount " : FieldAccumulator ( threshold : 2 ) ,
" unit " : FieldAccumulator ( threshold : 2 ) ,
" vendor " : FieldAccumulator ( threshold : 2 ) ,
" catalog " : FieldAccumulator ( threshold : 2 ) ,
]
// T r a c k s t h e l a s t C A S n u m b e r u s e d t o k i c k o f f a P u b C h e m t a s k .
// I f t h e a c c u m u l a t o r l a t e r c o n v e r g e s o n a d i f f e r e n t C A S , w e c a n c e l a n d r e t r y .
private var lastPubChemCAS : String = " "
// T r u e i f t h e l a s t P u b C h e m l o o k u p r e t u r n e d n o r e s u l t ( s o A I s h o u l d k e e p r u n n i n g )
private var pubChemFailed = false
// T r a c k s w h i c h f i e l d s h a v e b e e n c o n f i r m e d b y t h e r e g e x a c c u m u l a t o r ( 2 + O C R v o t e s ) .
// A I r e s u l t s n e v e r o v e r w r i t e t h e s e — r e g e x - c o n f i r m e d d a t a i s m o r e t r u s t w o r t h y f o r s t r u c t u r e d f i e l d s .
private var lockedByRegex : Set < String > = [ ]
// B a r c o d e : f i r s t h i t w i n s ( n o n e e d f o r a c c u m u l a t i o n )
private var barcodeHandled = false
private var pendingBarcode : String ? // b a r c o d e s e e n b e f o r e s c a n n i n g s t a r t e d
private var pubChemTask : Task < Void , Never > ?
// O n - d e v i c e A I e x t r a c t i o n
var isRunningAI = false
private var aiTask : Task < Void , Never > ?
private var allOCRLines : Set < String > = [ ] // a c c u m u l a t e s u n i q u e l i n e s s e e n a c r o s s f r a m e s
private var aiTriggered = false
private var aiNameConfidence : Int = 0 // c o n f i d e n c e o f t h e c u r r e n t l y a p p l i e d A I n a m e
private var lastAITriggerTime : Date = . distantPast // w a l l - c l o c k t i m e o f l a s t A I t r i g g e r
private var aiSatisfied = false // t r u e o n c e n a m e s h a v e b e e n r e c o n c i l e d — n o m o r e A I r e - t r i g g e r s
// I n c r e m e n t e d o n e v e r y r e s e t S c a n S t a t e ( ) . A s y n c t a s k s c a p t u r e t h i s v a l u e a t l a u n c h ;
// i f i t d i f f e r s w h e n t h e y c o m p l e t e , t h e r e s u l t b e l o n g s t o a p r e v i o u s s c a n a n d i s d i s c a r d e d .
private var scanGeneration : Int = 0
2026-03-22 02:36:24 -05:00
// T r u e o n c e t h e u s e r h a s p i c k e d a c o n t a i n e r t y p e f o r t h i s s c a n .
var containerTypeSelected = false
// T r u e w h e n t h e c o n t a i n e r t y p e p i c k e r s h e e t s h o u l d b e s h o w n .
var showContainerTypePicker = false
2026-03-22 01:35:53 -05:00
// D e b u g i n f o s h o w n i n b a n n e r
var debugBarcode : String = " — "
var debugOCRLineCount : Int = 0
var debugPubChemName : String = " — " // r a w n a m e r e t u r n e d b y P u b C h e m
var debugAIName : String = " — " // n a m e e x t r a c t e d b y o n - d e v i c e A I ( w i t h c o n f i d e n c e )
var debugArbitration : String = " — " // r e s u l t o f n a m e a r b i t r a t i o n
2026-03-22 02:36:24 -05:00
2026-03-22 01:35:53 -05:00
var debugAIQueryCount : Int = 0 // h o w m a n y t i m e s t h e A I e x t r a c t i o n h a s b e e n t r i g g e r e d
var allOCRLinesCount : Int { allOCRLines . count }
var showDebug : Bool = false
2026-03-20 02:06:19 -05:00
private let profileClient = ProfileClient ( )
func loadProfile ( ) async {
if let profile = try ? await profileClient . get ( ) {
piFirstName = profile . piFirstName
bldgCode = profile . bldgCode
lab = profile . lab
contact = profile . contact ? ? " "
}
}
2026-03-22 01:35:53 -05:00
// C a l l e d f r o m c a m e r a c o o r d i n a t o r o n e v e r y r e c o g n i z e d - i t e m s u p d a t e ( ~ e v e r y 0 . 5 s )
func processFrame ( texts : [ String ] , barcode : String ? , barcodeDebug : String ? , thumbnail : UIImage ? ) {
guard isScanning else {
// S t i l l b u f f e r b a r c o d e s s e e n b e f o r e p l a y i s t a p p e d
if let bc = barcode {
pendingBarcode = bc
debugBarcode = " buffered: \( barcodeDebug ? ? bc ) "
}
2026-03-20 02:06:19 -05:00
return
}
2026-03-22 02:36:24 -05:00
// K e e p t h u m b n a i l f r e s h e v e r y f r a m e ( u s e d b y t h e f i l l s l i d e r p r e v i e w )
if let thumb = thumbnail {
2026-03-22 01:35:53 -05:00
current . thumbnail = thumb
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
// D e b u g : t r a c k O C R l i n e c o u n t e v e r y f r a m e
debugOCRLineCount = texts . count
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
// B u f f e r b a r c o d e e v e n b e f o r e s c a n n i n g s t a r t s s o w e d o n ' t m i s s a q u i c k r e a d
if let bc = barcode {
pendingBarcode = bc
debugBarcode = barcodeDebug ? ? bc
}
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
// A p p l y b a r c o d e o n f i r s t h i t o n c e s c a n n i n g i s a c t i v e
if let bc = pendingBarcode , ! barcodeHandled {
barcodeHandled = true
current . barcode = bc
debugBarcode = " locked: \( debugBarcode ) "
if current . catalogNumber . isEmpty {
current . catalogNumber = bc
previewFields [ " catalog " ] = bc
}
}
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
// O C R e x t r a c t i o n
let extracted = extract ( from : texts )
for ( key , value ) in extracted {
let acc = accumulators [ key ]
if let locked = acc ? . observe ( value ) {
applyLocked ( key : key , value : locked )
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
// A l w a y s u p d a t e p r e v i e w w i t h b e s t g u e s s
if let guess = acc ? . bestGuess ? ? value as String ? {
previewFields [ key ] = guess
}
}
// A c c u m u l a t e a l l O C R l i n e s s e e n f o r A I e x t r a c t i o n
for line in texts {
let trimmed = line . trimmingCharacters ( in : . whitespacesAndNewlines )
if ! trimmed . isEmpty { allOCRLines . insert ( trimmed ) }
}
// T r i g g e r o n - d e v i c e A I n a m e e x t r a c t i o n w h e n :
// - N o C A S f o u n d y e t , O R C A S w a s f o u n d b u t P u b C h e m f a i l e d f o r i t , A N D
// - E i t h e r n o t y e t t r i g g e r e d , O R 5 s e c o n d s h a v e p a s s e d a n d n o A I q u e r y i s r u n n i n g
let enoughTimeSinceLastAI = Date ( ) . timeIntervalSince ( lastAITriggerTime ) >= 5
let pubChemUnavailable = current . casNumber . isEmpty || pubChemFailed
if ! aiSatisfied , pubChemUnavailable , allOCRLines . count >= 6 ,
( ! aiTriggered || ( enoughTimeSinceLastAI && ! isRunningAI ) ) {
triggerAIExtraction ( )
}
// K i c k o f f P u b C h e m l o o k u p w h e n C A S l o c k s i n , o r r e t r y i f C A S h a s c h a n g e d
if ! current . casNumber . isEmpty , current . casNumber != lastPubChemCAS {
let cas = current . casNumber
lastPubChemCAS = cas
pubChemFailed = false
pubChemTask ? . cancel ( )
pubChemTask = nil
debugPubChemName = " querying… "
debugArbitration = " — "
pubChemTask = Task {
isLookingUpPubChem = true
if let result = await pubChemLookup ( cas : cas ) {
// I g n o r e r e s u l t i f C A S h a s c h a n g e d a g a i n w h i l e w e w e r e w a i t i n g
guard cas = = current . casNumber else {
isLookingUpPubChem = false
return
}
if current . chemicalFormula . isEmpty , let f = result . chemicalFormula {
current . chemicalFormula = f
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
if current . molecularWeight . isEmpty , let mw = result . molecularWeight {
current . molecularWeight = mw
}
if current . physicalState . isEmpty , let ps = result . physicalState {
current . physicalState = ps
}
// B u i l d d e b u g s t r i n g s h o w i n g b o t h n a m e s
let iupac = result . iupacName ? ? " "
let common = result . commonName ? ? " "
debugPubChemName = common . isEmpty
? ( iupac . isEmpty ? " no name " : iupac )
: " \( common ) [ \( iupac . prefix ( 20 ) ) …] "
if let bestName = result . bestName {
current . chemicalName = bestName
previewFields [ " name " ] = bestName
debugArbitration = result . commonName != nil ? " synonym▶︎ \( bestName ) " : " IUPAC▶︎ \( bestName ) "
} else {
debugArbitration = " no name from PubChem "
}
// P u b C h e m r e s u l t i s d e f i n i t i v e — s t o p A I f r o m o v e r r i d i n g
aiNameConfidence = 100
aiSatisfied = true
} else {
debugPubChemName = " failed for \( cas ) "
debugArbitration = " — "
pubChemFailed = true // a l l o w A I t o k e e p r u n n i n g a s f a l l b a c k
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
isLookingUpPubChem = false
2026-03-20 02:06:19 -05:00
}
}
}
2026-03-22 01:35:53 -05:00
private func triggerAIExtraction ( ) {
aiTriggered = true
aiTask ? . cancel ( )
aiTask = nil
let lines = Array ( allOCRLines )
lastAITriggerTime = Date ( )
debugAIQueryCount += 1
debugAIName = " querying # \( debugAIQueryCount ) … "
let generation = scanGeneration
aiTask = Task {
isRunningAI = true
if #available ( iOS 18.1 , * ) {
if let result = await onDeviceLabelLookup ( ocrLines : lines ) ,
generation = = scanGeneration {
let conf = result . nameConfidence
let extracted = result . chemicalName . isEmpty ? " — " : result . chemicalName
debugAIName = " \( extracted ) ( \( conf ) %) "
// A p p l y n a m e i f : c o n f i d e n t e n o u g h A N D ( n o n a m e y e t O R h i g h e r c o n f i d e n c e t h a n p r e v i o u s A I g u e s s )
if ! result . chemicalName . isEmpty , conf >= aiNameConfidenceThreshold ,
( current . chemicalName . isEmpty || conf > aiNameConfidence ) {
current . chemicalName = result . chemicalName
previewFields [ " name " ] = result . chemicalName
aiNameConfidence = conf
}
// F o r s e c o n d a r y f i e l d s : A I c a n f r e e l y w r i t e o r u p d a t e v a l u e s u n l e s s
// t h e r e g e x a c c u m u l a t o r h a s a l r e a d y c o n f i r m e d t h e m ( 2 + O C R v o t e s = m o r e r e l i a b l e ) .
if ! result . physicalState . isEmpty {
current . physicalState = result . physicalState
}
if ! lockedByRegex . contains ( " vendor " ) , ! result . vendor . isEmpty {
current . vendor = result . vendor
previewFields [ " vendor " ] = result . vendor
}
// C A S : o n l y f i l l f r o m A I i f r e g e x h a s n ' t s e e n i t — r e g e x i s m o r e r e l i a b l e f o r s t r u c t u r e d n u m b e r s
if ! lockedByRegex . contains ( " cas " ) , ! result . casNumber . isEmpty {
current . casNumber = result . casNumber
previewFields [ " cas " ] = result . casNumber
}
if ! lockedByRegex . contains ( " lot " ) , ! result . lotNumber . isEmpty {
current . lotNumber = result . lotNumber
previewFields [ " lot " ] = result . lotNumber
}
if ! lockedByRegex . contains ( " exp " ) , ! result . expirationDate . isEmpty {
current . expirationDate = normalizeDate ( result . expirationDate )
previewFields [ " exp " ] = current . expirationDate
}
if ! lockedByRegex . contains ( " amount " ) , ! result . amount . isEmpty {
current . amountPerContainer = result . amount
previewFields [ " amount " ] = result . amount
}
if ! lockedByRegex . contains ( " unit " ) , ! result . unit . isEmpty {
current . unitOfMeasure = result . unit
previewFields [ " unit " ] = result . unit
}
if ! lockedByRegex . contains ( " catalog " ) , ! result . catalogNumber . isEmpty {
current . catalogNumber = result . catalogNumber
previewFields [ " catalog " ] = result . catalogNumber
}
} else {
debugAIName = " unavailable "
}
} else {
debugAIName = " iOS 18.1+ req "
}
isRunningAI = false
2026-03-20 02:06:19 -05:00
}
}
2026-03-22 01:35:53 -05:00
private func applyLocked ( key : String , value : String ) {
// M a r k f i e l d a s r e g e x - c o n f i r m e d . A I w i l l n o t o v e r w r i t e t h e s e .
lockedByRegex . insert ( key )
switch key {
case " cas " :
current . casNumber = value
case " lot " :
current . lotNumber = value
case " mw " :
current . molecularWeight = value
case " exp " :
current . expirationDate = value
case " amount " :
current . amountPerContainer = value
case " unit " :
current . unitOfMeasure = value
case " vendor " :
current . vendor = value
case " catalog " :
current . catalogNumber = value
default : break
2026-03-20 02:06:19 -05:00
}
}
2026-03-22 01:35:53 -05:00
private func resetScanState ( ) {
scanGeneration += 1
resetAccumulators ( )
current = ScannedItem ( )
previewFields = [ : ]
lockedByRegex = [ ]
barcodeHandled = false
pendingBarcode = nil
pubChemTask ? . cancel ( )
pubChemTask = nil
lastPubChemCAS = " "
pubChemFailed = false
aiTask ? . cancel ( )
aiTask = nil
isRunningAI = false
2026-03-22 02:36:24 -05:00
containerTypeSelected = false
showContainerTypePicker = false
2026-03-22 01:35:53 -05:00
aiTriggered = false
aiNameConfidence = 0
lastAITriggerTime = . distantPast
aiSatisfied = false
allOCRLines = [ ]
debugBarcode = " — "
debugOCRLineCount = 0
debugPubChemName = " — "
debugAIName = " — "
debugArbitration = " — "
debugAIQueryCount = 0
isLookingUpPubChem = false
}
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
func startScanning ( ) {
resetScanState ( )
isScanning = true
2026-03-22 02:36:24 -05:00
showContainerTypePicker = true
2026-03-22 01:35:53 -05:00
}
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
func stopScanning ( ) {
isScanning = false
let snapshot = current
scannedItems . append ( snapshot )
let appendedIndex = scannedItems . count - 1
// C a n c e l a n y i n - f l i g h t t a s k s b e f o r e s t a r t i n g t h e p o s t - s t o p A I p a s s
pubChemTask ? . cancel ( )
pubChemTask = nil
// F i n a l A I p a s s — u p d a t e t h e a l r e a d y - a p p e n d e d i t e m w h e n i t c o m p l e t e s
if snapshot . chemicalName . isEmpty , ! allOCRLines . isEmpty {
let lines = Array ( allOCRLines )
let lockedSnapshot = lockedByRegex // c a p t u r e b y v a l u e — r e s e t S c a n S t a t e c l e a r s t h e o r i g i n a l
let generation = scanGeneration
debugAIQueryCount += 1
debugAIName = " querying # \( debugAIQueryCount ) (post-stop)… "
aiTask ? . cancel ( )
aiTask = Task {
isRunningAI = true
if #available ( iOS 18.1 , * ) {
if let result = await onDeviceLabelLookup ( ocrLines : lines ) ,
generation = = scanGeneration ,
appendedIndex < scannedItems . count {
let conf = result . nameConfidence
let extracted = result . chemicalName . isEmpty ? " — " : result . chemicalName
debugAIName = " \( extracted ) ( \( conf ) %) "
if ! result . chemicalName . isEmpty , conf >= aiNameConfidenceThreshold ,
( scannedItems [ appendedIndex ] . chemicalName . isEmpty || conf > aiNameConfidence ) {
scannedItems [ appendedIndex ] . chemicalName = result . chemicalName
}
if ! result . physicalState . isEmpty {
scannedItems [ appendedIndex ] . physicalState = result . physicalState
}
if ! lockedSnapshot . contains ( " vendor " ) , ! result . vendor . isEmpty {
scannedItems [ appendedIndex ] . vendor = result . vendor
}
if ! lockedSnapshot . contains ( " cas " ) , ! result . casNumber . isEmpty {
scannedItems [ appendedIndex ] . casNumber = result . casNumber
}
if ! lockedSnapshot . contains ( " lot " ) , ! result . lotNumber . isEmpty {
scannedItems [ appendedIndex ] . lotNumber = result . lotNumber
}
if ! lockedSnapshot . contains ( " exp " ) , ! result . expirationDate . isEmpty {
scannedItems [ appendedIndex ] . expirationDate = normalizeDate ( result . expirationDate )
}
if ! lockedSnapshot . contains ( " amount " ) , ! result . amount . isEmpty {
scannedItems [ appendedIndex ] . amountPerContainer = result . amount
}
if ! lockedSnapshot . contains ( " unit " ) , ! result . unit . isEmpty {
scannedItems [ appendedIndex ] . unitOfMeasure = result . unit
}
if ! lockedSnapshot . contains ( " catalog " ) , ! result . catalogNumber . isEmpty {
scannedItems [ appendedIndex ] . catalogNumber = result . catalogNumber
}
} else {
debugAIName = " unavailable "
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
} else {
debugAIName = " iOS 18.1+ req "
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
isRunningAI = false
2026-03-20 02:06:19 -05:00
}
}
}
2026-03-22 01:35:53 -05:00
func finishSession ( ) {
isScanning = false
showReview = true
}
private func resetAccumulators ( ) {
accumulators . values . forEach { $0 . reset ( ) }
}
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
// MARK: - S c a n V i e w ( m a i n )
struct ScanView : View {
@ Environment ( \ . dismiss ) private var dismiss
@ State private var viewModel = LabelScannerViewModel ( )
2026-03-20 02:06:19 -05:00
var body : some View {
2026-03-22 01:35:53 -05:00
NavigationStack {
2026-03-20 02:06:19 -05:00
ZStack {
2026-03-22 01:35:53 -05:00
// C a m e r a f e e d ( a l w a y s l i v e )
if DataScannerViewController . isSupported && DataScannerViewController . isAvailable {
LiveScannerView ( viewModel : viewModel )
. ignoresSafeArea ( )
} else {
Color . black . ignoresSafeArea ( )
ContentUnavailableView (
" Scanner Unavailable " ,
systemImage : " camera.slash " ,
description : Text ( " This device does not support scanning. " )
)
}
// O v e r l a y
VStack ( spacing : 0 ) {
// B a n n e r
if viewModel . isScanning {
ScanBanner ( viewModel : viewModel )
. transition ( . move ( edge : . top ) . combined ( with : . opacity ) )
}
Spacer ( )
// B o t t o m c o n t r o l s
ScanBottomBar ( viewModel : viewModel )
}
}
. navigationBarTitleDisplayMode ( . inline )
. toolbarBackground ( . hidden , for : . navigationBar )
. toolbar {
ToolbarItem ( placement : . topBarLeading ) {
Button ( " Cancel " ) { dismiss ( ) }
. foregroundStyle ( . white )
. shadow ( radius : 2 )
}
ToolbarItem ( placement : . topBarTrailing ) {
if ! viewModel . scannedItems . isEmpty && ! viewModel . isScanning {
Button {
viewModel . finishSession ( )
} label : {
HStack ( spacing : 4 ) {
Image ( systemName : " checkmark " )
Text ( " Done ( \( viewModel . scannedItems . count ) ) " )
}
. fontWeight ( . semibold )
}
. foregroundStyle ( . white )
. shadow ( radius : 2 )
}
}
2026-03-20 02:06:19 -05:00
}
}
2026-03-22 01:35:53 -05:00
. task { await viewModel . loadProfile ( ) }
. sheet ( isPresented : $ viewModel . showReview ) {
ScanReviewView ( viewModel : viewModel , onDone : { dismiss ( ) } )
}
2026-03-22 02:36:24 -05:00
. sheet ( isPresented : $ viewModel . showContainerTypePicker ) {
ContainerTypePickerSheet ( viewModel : viewModel )
. presentationDetents ( [ . medium ] )
. presentationDragIndicator ( . visible )
}
2026-03-22 01:35:53 -05:00
. animation ( . easeInOut ( duration : 0.3 ) , value : viewModel . isScanning )
2026-03-20 02:06:19 -05:00
}
}
2026-03-22 01:35:53 -05:00
// MARK: - L i v e s c a n n e r U I V i e w C o n t r o l l e r R e p r e s e n t a b l e
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
struct LiveScannerView : UIViewControllerRepresentable {
let viewModel : LabelScannerViewModel
2026-03-20 02:06:19 -05:00
func makeUIViewController ( context : Context ) -> DataScannerViewController {
let scanner = DataScannerViewController (
recognizedDataTypes : [ . text ( ) , . barcode ( ) ] ,
2026-03-22 01:35:53 -05:00
qualityLevel : . accurate ,
2026-03-20 02:06:19 -05:00
recognizesMultipleItems : true ,
2026-03-22 01:35:53 -05:00
isHighFrameRateTrackingEnabled : false ,
isHighlightingEnabled : false // w e d r a w o u r o w n b a n n e r , n o o v e r l a y s n e e d e d
2026-03-20 02:06:19 -05:00
)
scanner . delegate = context . coordinator
2026-03-22 01:35:53 -05:00
context . coordinator . viewModel = viewModel
2026-03-20 02:06:19 -05:00
try ? scanner . startScanning ( )
return scanner
}
func updateUIViewController ( _ uiViewController : DataScannerViewController , context : Context ) { }
2026-03-22 01:35:53 -05:00
func makeCoordinator ( ) -> Coordinator { Coordinator ( ) }
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
@ MainActor
final class Coordinator : NSObject , DataScannerViewControllerDelegate , @ unchecked Sendable {
var viewModel : LabelScannerViewModel ?
private var lastProcessed = Date . distantPast
2026-03-20 02:06:19 -05:00
func dataScanner ( _ dataScanner : DataScannerViewController , didAdd addedItems : [ RecognizedItem ] , allItems : [ RecognizedItem ] ) {
2026-03-22 01:35:53 -05:00
// P r o c e s s i m m e d i a t e l y w h e n n e w i t e m s a r e f i r s t r e c o g n i z e d ( c a t c h e s b a r c o d e s t h a t d o n ' t t r i g g e r d i d U p d a t e )
processItems ( allItems , in : dataScanner , forceBarcode : true )
2026-03-20 02:06:19 -05:00
}
func dataScanner ( _ dataScanner : DataScannerViewController , didUpdate updatedItems : [ RecognizedItem ] , allItems : [ RecognizedItem ] ) {
2026-03-22 01:35:53 -05:00
// T h r o t t l e t o ~ 2 f r a m e s / s e c t o a v o i d h a m m e r i n g
let now = Date ( )
guard now . timeIntervalSince ( lastProcessed ) >= 0.5 else { return }
lastProcessed = now
processItems ( allItems , in : dataScanner , forceBarcode : false )
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
private func processItems ( _ allItems : [ RecognizedItem ] , in dataScanner : DataScannerViewController , forceBarcode : Bool ) {
var texts : [ String ] = [ ]
var barcode : String ? // r a w p a y l o a d s t o r e d i n t h e i t e m
var barcodeDebug : String ? // i n c l u d e s s y m b o l o g y , s h o w n o n s c r e e n o n l y
for item in allItems {
switch item {
case . text ( let t ) : texts . append ( t . transcript )
case . barcode ( let b ) :
if let p = b . payloadStringValue {
barcode = p
let sym = b . observation . symbology . rawValue . replacingOccurrences ( of : " VNBarcodeSymbology " , with : " " )
barcodeDebug = " [ \( sym ) ] \( p ) "
}
default : break
}
}
// S k i p i f n o b a r c o d e f o u n d a n d t h i s w a s o n l y c a l l e d f o r b a r c o d e d e t e c t i o n
if forceBarcode && barcode = = nil { return }
// C a p t u r e t h u m b n a i l
let thumb : UIImage ? = {
let renderer = UIGraphicsImageRenderer ( bounds : dataScanner . view . bounds )
return renderer . image { ctx in dataScanner . view . layer . render ( in : ctx . cgContext ) }
} ( )
Task { @ MainActor in
viewModel ? . processFrame ( texts : texts , barcode : barcode , barcodeDebug : barcodeDebug , thumbnail : thumb )
}
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
}
}
2026-03-20 02:06:19 -05:00
2026-03-22 02:36:24 -05:00
// MARK: - C o n t a i n e r t y p e p i c k e r s h e e t
struct ContainerTypePickerSheet : View {
@ Bindable var viewModel : LabelScannerViewModel
var body : some View {
VStack ( alignment : . leading , spacing : 0 ) {
Text ( " Container Type " )
. font ( . headline )
. padding ( . horizontal )
. padding ( . top , 20 )
. padding ( . bottom , 12 )
ScrollView {
LazyVGrid ( columns : [ GridItem ( . flexible ( ) ) , GridItem ( . flexible ( ) ) ] , spacing : 12 ) {
ForEach ( containerTypeOptions , id : \ . self ) { option in
let isSelected = viewModel . current . storageDevice = = option
Button {
viewModel . current . storageDevice = option
viewModel . previewFields [ " container " ] = option
viewModel . containerTypeSelected = true
viewModel . showContainerTypePicker = false
} label : {
Text ( option )
. font ( . subheadline )
. frame ( maxWidth : . infinity )
. padding ( . vertical , 14 )
. background ( isSelected ? Color . accentColor : Color ( . secondarySystemBackground ) )
. foregroundStyle ( isSelected ? . white : . primary )
. clipShape ( RoundedRectangle ( cornerRadius : 10 ) )
}
}
}
. padding ( . horizontal )
. padding ( . bottom , 20 )
}
}
}
}
2026-03-22 01:35:53 -05:00
// MARK: - S c a n b a n n e r
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
struct ScanBanner : View {
@ Bindable var viewModel : LabelScannerViewModel
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
var body : some View {
VStack ( alignment : . leading , spacing : 0 ) {
// H e a d e r
HStack {
if viewModel . isLookingUpPubChem || viewModel . isRunningAI {
ProgressView ( ) . scaleEffect ( 0.7 ) . tint ( . white )
} else {
Image ( systemName : " checkmark.circle.fill " )
. foregroundStyle ( Color ( . brandPrimary ) )
}
Text ( " Scanning… " )
. font ( . subheadline . weight ( . semibold ) )
. foregroundStyle ( . white )
Spacer ( )
Button {
viewModel . showDebug . toggle ( )
} label : {
Label ( " Debug " , systemImage : viewModel . showDebug ? " chevron.up.circle.fill " : " chevron.down.circle " )
. font ( . caption2 )
. foregroundStyle ( . white . opacity ( viewModel . showDebug ? 1 : 0.55 ) )
. labelStyle ( . iconOnly )
}
. buttonStyle ( . plain )
}
. padding ( . horizontal , 16 )
. padding ( . top , 12 )
. padding ( . bottom , 8 )
Divider ( ) . background ( . white . opacity ( 0.3 ) )
// F i e l d s g r i d
let fields : [ ( String , String ) ] = [
( " Chemical " , viewModel . current . chemicalName . isEmpty
? ( viewModel . previewFields [ " name " ] ? ? " — " ) : viewModel . current . chemicalName ) ,
( " CAS " , viewModel . current . casNumber . isEmpty
? ( viewModel . previewFields [ " cas " ] ? ? " — " ) : viewModel . current . casNumber ) ,
( " Lot " , viewModel . current . lotNumber . isEmpty
? ( viewModel . previewFields [ " lot " ] ? ? " — " ) : viewModel . current . lotNumber ) ,
( " Vendor " , viewModel . current . vendor . isEmpty
? ( viewModel . previewFields [ " vendor " ] ? ? " — " ) : viewModel . current . vendor ) ,
( " Amount " , viewModel . current . amountPerContainer . isEmpty
? amountPreview : " \( viewModel . current . amountPerContainer ) \( viewModel . current . unitOfMeasure ) " ) ,
( " Exp " , viewModel . current . expirationDate . isEmpty
? ( viewModel . previewFields [ " exp " ] ? ? " — " ) : viewModel . current . expirationDate ) ,
( " Container " , viewModel . current . storageDevice . isEmpty
? ( viewModel . previewFields [ " container " ] ? ? " — " ) : viewModel . current . storageDevice ) ,
]
LazyVGrid ( columns : [ GridItem ( . flexible ( ) ) , GridItem ( . flexible ( ) ) ] , spacing : 6 ) {
ForEach ( fields , id : \ . 0 ) { label , value in
BannerField ( label : label , value : value )
}
}
. padding ( . horizontal , 16 )
. padding ( . vertical , 10 )
// D e b u g s t r i p ( c o l l a p s i b l e )
if viewModel . showDebug {
Divider ( ) . background ( . white . opacity ( 0.2 ) )
VStack ( alignment : . leading , spacing : 2 ) {
HStack ( spacing : 4 ) {
Text ( " BCR " ) . font ( . system ( size : 9 , design : . monospaced ) ) . foregroundStyle ( . yellow . opacity ( 0.7 ) ) . frame ( width : 28 , alignment : . leading )
Text ( viewModel . debugBarcode ) . font ( . system ( size : 9 , design : . monospaced ) ) . foregroundStyle ( . yellow ) . lineLimit ( 1 ) . truncationMode ( . middle )
}
HStack ( spacing : 4 ) {
Text ( " OCR " ) . font ( . system ( size : 9 , design : . monospaced ) ) . foregroundStyle ( . cyan . opacity ( 0.7 ) ) . frame ( width : 28 , alignment : . leading )
Text ( " \( viewModel . debugOCRLineCount ) lines · \( viewModel . allOCRLinesCount ) unique accumulated " ) . font ( . system ( size : 9 , design : . monospaced ) ) . foregroundStyle ( . cyan ) . lineLimit ( 1 )
}
HStack ( spacing : 4 ) {
Text ( " AI× \( viewModel . debugAIQueryCount ) " ) . font ( . system ( size : 9 , design : . monospaced ) ) . foregroundStyle ( . orange . opacity ( 0.7 ) ) . frame ( width : 28 , alignment : . leading )
Text ( viewModel . debugAIName ) . font ( . system ( size : 9 , design : . monospaced ) ) . foregroundStyle ( . orange ) . lineLimit ( 1 ) . truncationMode ( . tail )
}
HStack ( spacing : 4 ) {
Text ( " PUB " ) . font ( . system ( size : 9 , design : . monospaced ) ) . foregroundStyle ( . green . opacity ( 0.7 ) ) . frame ( width : 28 , alignment : . leading )
Text ( viewModel . debugPubChemName ) . font ( . system ( size : 9 , design : . monospaced ) ) . foregroundStyle ( . green ) . lineLimit ( 1 ) . truncationMode ( . tail )
}
HStack ( spacing : 4 ) {
Text ( " ARB " ) . font ( . system ( size : 9 , design : . monospaced ) ) . foregroundStyle ( . mint . opacity ( 0.7 ) ) . frame ( width : 28 , alignment : . leading )
Text ( viewModel . debugArbitration ) . font ( . system ( size : 9 , design : . monospaced ) ) . foregroundStyle ( . mint ) . lineLimit ( 1 ) . truncationMode ( . tail )
2026-03-20 02:06:19 -05:00
}
2026-03-22 02:36:24 -05:00
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
. padding ( . horizontal , 16 )
. padding ( . vertical , 6 )
. transition ( . opacity . combined ( with : . move ( edge : . top ) ) )
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
// F i l l s l i d e r
HStack ( spacing : 12 ) {
Text ( " Fill level " )
. font ( . caption )
. foregroundStyle ( . white . opacity ( 0.8 ) )
Slider ( value : $ viewModel . current . percentageFull , in : 0. . . 100 , step : 5 )
. tint ( Color ( . brandPrimary ) )
Text ( " \( Int ( viewModel . current . percentageFull ) ) % " )
. font ( . caption . monospacedDigit ( ) )
. foregroundStyle ( . white )
. frame ( width : 36 , alignment : . trailing )
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
. padding ( . horizontal , 16 )
. padding ( . bottom , 12 )
}
. background ( . ultraThinMaterial . opacity ( 0.92 ) )
. background ( Color . black . opacity ( 0.55 ) )
. animation ( . easeInOut ( duration : 0.2 ) , value : viewModel . showDebug )
}
private var amountPreview : String {
let a = viewModel . previewFields [ " amount " ] ? ? " "
let u = viewModel . previewFields [ " unit " ] ? ? " "
return ( a . isEmpty && u . isEmpty ) ? " — " : " \( a ) \( u ) " . trimmingCharacters ( in : . whitespaces )
}
}
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
private struct BannerField : View {
let label : String
let value : String
var locked : Bool { value != " — " }
var body : some View {
HStack ( spacing : 4 ) {
Text ( label )
. font ( . caption2 )
. foregroundStyle ( . white . opacity ( 0.6 ) )
. frame ( width : 46 , alignment : . leading )
Text ( value )
. font ( . caption . weight ( locked ? . semibold : . regular ) )
. foregroundStyle ( locked ? . white : . white . opacity ( 0.4 ) )
. lineLimit ( 1 )
. truncationMode ( . tail )
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
. frame ( maxWidth : . infinity , alignment : . leading )
2026-03-20 02:06:19 -05:00
}
}
2026-03-22 01:35:53 -05:00
// MARK: - B o t t o m b a r
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
struct ScanBottomBar : View {
let viewModel : LabelScannerViewModel
2026-03-20 02:06:19 -05:00
var body : some View {
2026-03-22 01:35:53 -05:00
HStack ( spacing : 24 ) {
// S c a n n e d c o u n t b a d g e
VStack ( spacing : 2 ) {
Text ( " \( viewModel . scannedItems . count ) " )
. font ( . title2 . bold ( ) )
. foregroundStyle ( . white )
Text ( " saved " )
. font ( . caption2 )
. foregroundStyle ( . white . opacity ( 0.7 ) )
}
. frame ( width : 52 )
// S t a r t / S t o p b u t t o n
Button {
if viewModel . isScanning {
viewModel . stopScanning ( )
} else {
viewModel . startScanning ( )
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
} label : {
ZStack {
Circle ( )
. fill ( viewModel . isScanning ? Color ( . brandDestructive ) : Color ( . brandPrimary ) )
. frame ( width : 72 , height : 72 )
Image ( systemName : viewModel . isScanning ? " stop.fill " : " play.fill " )
. font ( . title2 )
. foregroundStyle ( . white )
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
}
. shadow ( radius : 4 )
// H i n t
VStack ( spacing : 2 ) {
Text ( viewModel . isScanning ? " Tap stop \n when done " : " Tap play \n to start " )
. font ( . caption2 )
. foregroundStyle ( . white . opacity ( 0.7 ) )
. multilineTextAlignment ( . center )
}
. frame ( width : 52 )
}
. padding ( . vertical , 20 )
. padding ( . horizontal , 32 )
. background ( . ultraThinMaterial . opacity ( 0.85 ) )
. background ( Color . black . opacity ( 0.4 ) )
}
}
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
// MARK: - R e v i e w v i e w
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
struct ScanReviewView : View {
@ Bindable var viewModel : LabelScannerViewModel
let onDone : ( ) -> Void
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
@ State private var isSavingAll = false
@ State private var saveError : String ?
@ State private var editingItem : ScannedItem ?
@ State private var savedCount = 0
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
private let chemicalsClient = ChemicalsClient ( )
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
var body : some View {
NavigationStack {
List {
Section {
Text ( " Review \( viewModel . scannedItems . count ) scanned item \( viewModel . scannedItems . count = = 1 ? " " : " s " ) . Tap any item to edit before saving. " )
. font ( . subheadline )
. foregroundStyle ( . secondary )
}
ForEach ( viewModel . scannedItems ) { item in
ScannedItemRow ( item : item )
. contentShape ( Rectangle ( ) )
. onTapGesture { editingItem = item }
. swipeActions ( edge : . trailing ) {
Button ( role : . destructive ) {
viewModel . scannedItems . removeAll { $0 . id = = item . id }
} label : {
Label ( " Delete " , systemImage : " trash " )
2026-03-20 02:06:19 -05:00
}
}
2026-03-22 01:35:53 -05:00
}
if let error = saveError {
Section {
Text ( error ) . foregroundStyle ( . red ) . font ( . footnote )
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
}
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
Section {
Button {
Task { await saveAll ( ) }
} label : {
if isSavingAll {
HStack {
ProgressView ( ) . scaleEffect ( 0.8 )
Text ( " Saving \( savedCount ) / \( viewModel . scannedItems . count ) … " )
}
. frame ( maxWidth : . infinity )
} else {
Text ( " Save All to Inventory " )
. fontWeight ( . semibold )
. frame ( maxWidth : . infinity )
2026-03-20 02:06:19 -05:00
}
}
2026-03-22 01:35:53 -05:00
. buttonStyle ( . borderedProminent )
. tint ( Color ( . brandPrimary ) )
. disabled ( viewModel . scannedItems . isEmpty || isSavingAll )
}
}
. navigationTitle ( " Review Scans " )
. navigationBarTitleDisplayMode ( . inline )
. toolbar {
ToolbarItem ( placement : . topBarLeading ) {
Button ( " Back " ) {
viewModel . showReview = false
}
}
}
}
. sheet ( item : $ editingItem ) { item in
ScannedItemEditView (
item : item ,
piFirstName : viewModel . piFirstName ,
bldgCode : viewModel . bldgCode ,
lab : viewModel . lab ,
storageLocation : viewModel . storageLocation ,
contact : viewModel . contact
) { updated in
if let idx = viewModel . scannedItems . firstIndex ( where : { $0 . id = = updated . id } ) {
viewModel . scannedItems [ idx ] = updated
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
editingItem = nil
2026-03-20 02:06:19 -05:00
}
}
}
2026-03-22 01:35:53 -05:00
private func saveAll ( ) async {
isSavingAll = true
saveError = nil
savedCount = 0
var failed = 0
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
for item in viewModel . scannedItems {
let body = ChemicalCreateBody (
piFirstName : viewModel . piFirstName . isEmpty ? " Unknown " : viewModel . piFirstName ,
physicalState : item . physicalState . isEmpty ? " Solid " : item . physicalState ,
chemicalName : item . chemicalName . isEmpty ? " Unknown Chemical " : item . chemicalName ,
bldgCode : viewModel . bldgCode . isEmpty ? " Unknown " : viewModel . bldgCode ,
lab : viewModel . lab . isEmpty ? " Unknown " : viewModel . lab ,
storageLocation : viewModel . storageLocation . isEmpty ? " Unknown " : viewModel . storageLocation ,
storageDevice : item . storageDevice ,
numberOfContainers : " 1 " ,
amountPerContainer : item . amountPerContainer . isEmpty ? " Unknown " : item . amountPerContainer ,
unitOfMeasure : item . unitOfMeasure . isEmpty ? " Unknown " : item . unitOfMeasure ,
casNumber : item . casNumber . isEmpty ? " Unknown " : item . casNumber ,
chemicalFormula : item . chemicalFormula . isEmpty ? nil : item . chemicalFormula ,
molecularWeight : item . molecularWeight . isEmpty ? nil : item . molecularWeight ,
vendor : item . vendor . isEmpty ? nil : item . vendor ,
catalogNumber : item . catalogNumber . isEmpty ? nil : item . catalogNumber ,
lotNumber : item . lotNumber . isEmpty ? nil : item . lotNumber ,
expirationDate : item . expirationDate . isEmpty ? nil : item . expirationDate ,
concentration : item . concentration . isEmpty ? nil : item . concentration ,
percentageFull : item . percentageFull ,
barcode : item . barcode ,
contact : viewModel . contact . isEmpty ? nil : viewModel . contact
)
do {
_ = try await chemicalsClient . create ( body )
savedCount += 1
} catch {
failed += 1
}
}
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
isSavingAll = false
if failed > 0 {
saveError = " \( failed ) item(s) failed to save. The rest were saved successfully. "
} else {
onDone ( )
}
}
}
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
// MARK: - S c a n n e d i t e m r o w ( r e v i e w l i s t )
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
private struct ScannedItemRow : View {
let item : ScannedItem
2026-03-20 02:06:19 -05:00
2026-03-22 01:35:53 -05:00
var body : some View {
HStack ( spacing : 12 ) {
if let thumb = item . thumbnail {
Image ( uiImage : thumb )
. resizable ( )
. scaledToFill ( )
. frame ( width : 52 , height : 52 )
. clipShape ( RoundedRectangle ( cornerRadius : 8 ) )
} else {
RoundedRectangle ( cornerRadius : 8 )
. fill ( Color . secondary . opacity ( 0.15 ) )
. frame ( width : 52 , height : 52 )
. overlay {
Image ( systemName : " flask.fill " )
. foregroundStyle ( . secondary )
}
}
VStack ( alignment : . leading , spacing : 3 ) {
Text ( item . chemicalName . isEmpty ? " Unnamed chemical " : item . chemicalName )
. font ( . subheadline . weight ( . semibold ) )
. lineLimit ( 1 )
HStack ( spacing : 6 ) {
if ! item . casNumber . isEmpty {
Text ( " CAS: \( item . casNumber ) " ) . font ( . caption ) . foregroundStyle ( . secondary )
}
if ! item . vendor . isEmpty {
Text ( " · \( item . vendor ) " ) . font ( . caption ) . foregroundStyle ( . secondary )
}
}
HStack ( spacing : 6 ) {
if ! item . amountPerContainer . isEmpty {
Text ( " \( item . amountPerContainer ) \( item . unitOfMeasure ) " ) . font ( . caption2 ) . foregroundStyle ( . secondary )
}
Text ( " · \( Int ( item . percentageFull ) ) % full " ) . font ( . caption2 ) . foregroundStyle ( . secondary )
2026-03-20 02:06:19 -05:00
}
}
2026-03-22 01:35:53 -05:00
Spacer ( )
Image ( systemName : " chevron.right " )
. font ( . caption )
. foregroundStyle ( . tertiary )
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
. padding ( . vertical , 4 )
2026-03-20 02:06:19 -05:00
}
}
2026-03-22 01:35:53 -05:00
// MARK: - E d i t a s i n g l e s c a n n e d i t e m
struct ScannedItemEditView : View {
@ State private var item : ScannedItem
@ State private var piFirstName : String
@ State private var bldgCode : String
@ State private var lab : String
@ State private var storageLocation : String
@ State private var contact : String
let onSave : ( ScannedItem ) -> Void
init ( item : ScannedItem , piFirstName : String , bldgCode : String , lab : String ,
storageLocation : String , contact : String , onSave : @ escaping ( ScannedItem ) -> Void ) {
_item = State ( initialValue : item )
_piFirstName = State ( initialValue : piFirstName )
_bldgCode = State ( initialValue : bldgCode )
_lab = State ( initialValue : lab )
_storageLocation = State ( initialValue : storageLocation )
_contact = State ( initialValue : contact )
self . onSave = onSave
}
2026-03-20 02:06:19 -05:00
var body : some View {
2026-03-22 01:35:53 -05:00
NavigationStack {
Form {
Section ( " Identity " ) {
LabeledTextField ( " Chemical Name " , text : $ item . chemicalName )
LabeledTextField ( " CAS # " , text : $ item . casNumber )
Picker ( " Physical State " , selection : $ item . physicalState ) {
Text ( " Select… " ) . tag ( " " )
ForEach ( [ " Solid " , " Liquid " , " Gas " ] , id : \ . self ) { Text ( $0 ) . tag ( $0 ) }
2026-03-20 02:06:19 -05:00
}
2026-03-22 01:35:53 -05:00
Picker ( " Storage Device " , selection : $ item . storageDevice ) {
ForEach ( AddChemicalViewModel . storageDevices , id : \ . self ) { Text ( $0 ) . tag ( $0 ) }
}
}
Section ( " Location " ) {
LabeledTextField ( " PI First Name " , text : $ piFirstName )
LabeledTextField ( " Building Code " , text : $ bldgCode )
LabeledTextField ( " Lab " , text : $ lab )
LabeledTextField ( " Storage Location " , text : $ storageLocation )
}
Section ( " Quantity " ) {
LabeledTextField ( " Amount / Container " , text : $ item . amountPerContainer )
LabeledTextField ( " Unit of Measure " , text : $ item . unitOfMeasure )
HStack {
Text ( " Fill Level " )
Spacer ( )
Text ( " \( Int ( item . percentageFull ) ) % " )
. foregroundStyle ( . secondary )
}
Slider ( value : $ item . percentageFull , in : 0. . . 100 , step : 5 )
. tint ( Color ( . brandPrimary ) )
}
Section ( " Details " ) {
LabeledTextField ( " Formula " , text : $ item . chemicalFormula )
LabeledTextField ( " MW " , text : $ item . molecularWeight )
LabeledTextField ( " Vendor " , text : $ item . vendor )
LabeledTextField ( " Catalog # " , text : $ item . catalogNumber )
LabeledTextField ( " Lot # " , text : $ item . lotNumber )
LabeledTextField ( " Expiration " , text : $ item . expirationDate )
LabeledTextField ( " Concentration " , text : $ item . concentration )
}
}
. navigationTitle ( " Edit Item " )
. navigationBarTitleDisplayMode ( . inline )
. toolbar {
ToolbarItem ( placement : . topBarTrailing ) {
Button ( " Save " ) {
var updated = item
updated . percentageFull = item . percentageFull
onSave ( updated )
}
. fontWeight ( . semibold )
}
}
2026-03-20 02:06:19 -05:00
}
}
}
2026-03-22 01:35:53 -05:00
private struct LabeledTextField : View {
2026-03-20 02:06:19 -05:00
let label : String
@ Binding var text : String
2026-03-22 01:35:53 -05:00
init ( _ label : String , text : Binding < String > ) {
2026-03-20 02:06:19 -05:00
self . label = label
self . _text = text
}
var body : some View {
HStack {
2026-03-22 01:35:53 -05:00
Text ( label ) . foregroundStyle ( Color ( UIColor . label ) )
Spacer ( minLength : 8 )
2026-03-20 02:06:19 -05:00
TextField ( label , text : $ text )
. multilineTextAlignment ( . trailing )
2026-03-22 01:35:53 -05:00
. foregroundStyle ( Color ( UIColor . secondaryLabel ) )
2026-03-20 02:06:19 -05:00
}
}
}