import SwiftUI import VisionKit import Vision import LabWiseKit import FoundationModels // MARK: - On-device AI label extraction @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 } 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. """ } private let aiNameConfidenceThreshold = 60 // minimum confidence to apply AI-extracted name // MARK: - Name arbitration @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 } 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. """ } @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)\nLabel name: \(labelName)" return try? await session.respond(to: prompt, generating: NameArbitration.self).content } /// Strings the model emits when it has nothing real to return. private let aiNullishValues: Set = [ "", "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" ] /// Returns nil if the string is a model-generated placeholder rather than real data. private func aiSanitize(_ value: String) -> String { let lowered = value.trimmingCharacters(in: .whitespaces).lowercased() if aiNullishValues.contains(lowered) { return "" } return value.trimmingCharacters(in: .whitespaces) } /// Validates and sanitizes a date string. Returns empty string if it doesn't /// look like a real date (e.g. "0000-00-00", "1900-01-01", "9999-99-99"). private func aiSanitizeDate(_ value: String) -> String { let s = aiSanitize(value) guard !s.isEmpty else { return "" } // Reject zero/placeholder dates let stripped = s.replacingOccurrences(of: "-", with: "") .replacingOccurrences(of: "/", with: "") .replacingOccurrences(of: "0", with: "") if stripped.isEmpty { return "" } // all zeros after stripping separators // Must contain a plausible 4-digit year (1900–2099) 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 } /// Validates a CAS number — must match the XXXXXXX-XX-X pattern. 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 } /// Validates a numeric amount — must be a positive number. private func aiSanitizeAmount(_ value: String) -> String { let s = aiSanitize(value) guard !s.isEmpty, let d = Double(s), d > 0 else { return "" } return s } /// Sanitizes a unit string — must be a known lab unit, not a filler word. private let knownUnits: Set = [ "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 : "" } // MARK: - Container type options /// All container types available for the user to select. let containerTypeOptions: [String] = [ "Glass Bottle", "Plastic Bottle", "Vial", "Ampoule", "Flask", "Beaker", "Tube", "Jar", "Can", "Canister", "Drum", "Bag" ] @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 } // Sanitize every field so callers never receive placeholders like "N/A", "0000-00-00", etc. 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 } // MARK: - Scanned item (one bottle's worth of data) struct ScannedItem: Identifiable { let id = UUID() var chemicalName = "" var casNumber = "" var chemicalFormula = "" var molecularWeight = "" var vendor = "" var catalogNumber = "" var lotNumber = "" var concentration = "" var expirationDate = "" var physicalState = "" var storageDevice = "Glass Bottle" var amountPerContainer = "" var unitOfMeasure = "" var barcode: String? var percentageFull: Double = 50 var thumbnail: UIImage? } // MARK: - Confidence accumulator /// Tracks how many times a given value has been seen for a field. /// A field "locks in" once the same value is seen `threshold` times. private final class FieldAccumulator { private var votes: [String: Int] = [:] private let threshold: Int init(threshold: Int = 2) { self.threshold = threshold } /// Register a new observation. Returns the winning value if it /// has now reached the threshold, otherwise nil. 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 = [:] } /// Best guess even before threshold (for live preview). var bestGuess: String? { votes.max(by: { $0.value < $1.value })?.key } } // MARK: - PubChem lookup private struct PubChemResult { var iupacName: String? // raw IUPAC/preferred name from props var commonName: String? // best short synonym (e.g. "CHAPS", "HEPES") var chemicalFormula: String? var molecularWeight: String? var physicalState: String? /// The name to actually display: common name if one was found, otherwise IUPAC. var bestName: String? { commonName ?? iupacName } } /// Patterns that indicate a synonym is a database ID rather than a usable name. private let synonymJunkPatterns: [NSRegularExpression] = [ try! NSRegularExpression(pattern: #"^\d{2,7}-\d{2}-\d$"#), // CAS number try! NSRegularExpression(pattern: #"^[A-Z]{2,}[:\-_]\d"#), // DTXSID6…, CHEBI:… try! NSRegularExpression(pattern: #"^[A-Z0-9]{8,}$"#), // InChIKey-style all-caps try! NSRegularExpression(pattern: #"(?i)InChI="#), // InChI strings ] private func isJunkSynonym(_ s: String) -> Bool { let range = NSRange(s.startIndex..., in: s) return synonymJunkPatterns.contains { $0.firstMatch(in: s, range: range) != nil } } /// Returns the best human-readable synonym: shortest clean name that isn't the IUPAC name itself. /// Prefers names ≤ 30 characters (abbreviations/common names) but will take up to 60 if nothing shorter exists. 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 } // Prefer short names first (abbreviations like CHAPS, HEPES, SDS) 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() // Extract CID for synonym lookup, plus formula/weight/state/IUPAC name 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" } } } } // Fetch synonyms to find common/trade name (e.g. "CHAPS" instead of the long IUPAC string) 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: - Regex extractors 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 } } // Catalog number: line matching vendor catalog patterns like "P0662-500G", "BDH9258-500G" 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) // Month-name formats: "Jun. 2018", "June 2018", "jun 2018" 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) } // Numeric formats: MM/YYYY, MM-YYYY, YYYY/MM, YYYY-MM, YYYY-MM-DD 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: - Scanner view model @Observable @MainActor final class LabelScannerViewModel { // Session state var isScanning = false // live scanning session active var scannedItems: [ScannedItem] = [] var showReview = false // Current item being built var current = ScannedItem() var isLookingUpPubChem = false // Live preview fields (best-guess, updates every frame) var previewFields: [String: String] = [:] // Profile (pre-filled on location fields) var piFirstName = "" var bldgCode = "" var lab = "" var storageLocation = "" var contact = "" // Accumulators for current item. // CAS uses a higher threshold — a partial OCR read (e.g. missing last digit) looks valid // to the regex but is wrong. More votes = more confidence before committing. 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), ] // Tracks the last CAS number used to kick off a PubChem task. // If the accumulator later converges on a different CAS, we cancel and retry. private var lastPubChemCAS: String = "" // True if the last PubChem lookup returned no result (so AI should keep running) private var pubChemFailed = false // Tracks which fields have been confirmed by the regex accumulator (2+ OCR votes). // AI results never overwrite these — regex-confirmed data is more trustworthy for structured fields. private var lockedByRegex: Set = [] // Barcode: first hit wins (no need for accumulation) private var barcodeHandled = false private var pendingBarcode: String? // barcode seen before scanning started private var pubChemTask: Task? // On-device AI extraction var isRunningAI = false private var aiTask: Task? private var allOCRLines: Set = [] // accumulates unique lines seen across frames private var aiTriggered = false private var aiNameConfidence: Int = 0 // confidence of the currently applied AI name private var lastAITriggerTime: Date = .distantPast // wall-clock time of last AI trigger private var aiSatisfied = false // true once names have been reconciled — no more AI re-triggers // Incremented on every resetScanState(). Async tasks capture this value at launch; // if it differs when they complete, the result belongs to a previous scan and is discarded. private var scanGeneration: Int = 0 // True once the user has picked a container type for this scan. var containerTypeSelected = false // True when the container type picker sheet should be shown. var showContainerTypePicker = false // Debug info shown in banner var debugBarcode: String = "—" var debugOCRLineCount: Int = 0 var debugPubChemName: String = "—" // raw name returned by PubChem var debugAIName: String = "—" // name extracted by on-device AI (with confidence) var debugArbitration: String = "—" // result of name arbitration var debugAIQueryCount: Int = 0 // how many times the AI extraction has been triggered var allOCRLinesCount: Int { allOCRLines.count } var showDebug: Bool = false 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 ?? "" } } // Called from camera coordinator on every recognized-items update (~every 0.5s) func processFrame(texts: [String], barcode: String?, barcodeDebug: String?, thumbnail: UIImage?) { guard isScanning else { // Still buffer barcodes seen before play is tapped if let bc = barcode { pendingBarcode = bc debugBarcode = "buffered: \(barcodeDebug ?? bc)" } return } // Keep thumbnail fresh every frame (used by the fill slider preview) if let thumb = thumbnail { current.thumbnail = thumb } // Debug: track OCR line count every frame debugOCRLineCount = texts.count // Buffer barcode even before scanning starts so we don't miss a quick read if let bc = barcode { pendingBarcode = bc debugBarcode = barcodeDebug ?? bc } // Apply barcode on first hit once scanning is active if let bc = pendingBarcode, !barcodeHandled { barcodeHandled = true current.barcode = bc debugBarcode = "locked: \(debugBarcode)" if current.catalogNumber.isEmpty { current.catalogNumber = bc previewFields["catalog"] = bc } } // OCR extraction 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) } // Always update preview with best guess if let guess = acc?.bestGuess ?? value as String? { previewFields[key] = guess } } // Accumulate all OCR lines seen for AI extraction for line in texts { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { allOCRLines.insert(trimmed) } } // Trigger on-device AI name extraction when: // - No CAS found yet, OR CAS was found but PubChem failed for it, AND // - Either not yet triggered, OR 5 seconds have passed and no AI query is running let enoughTimeSinceLastAI = Date().timeIntervalSince(lastAITriggerTime) >= 5 let pubChemUnavailable = current.casNumber.isEmpty || pubChemFailed if !aiSatisfied, pubChemUnavailable, allOCRLines.count >= 6, (!aiTriggered || (enoughTimeSinceLastAI && !isRunningAI)) { triggerAIExtraction() } // Kick off PubChem lookup when CAS locks in, or retry if CAS has changed 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) { // Ignore result if CAS has changed again while we were waiting guard cas == current.casNumber else { isLookingUpPubChem = false return } if current.chemicalFormula.isEmpty, let f = result.chemicalFormula { current.chemicalFormula = f } if current.molecularWeight.isEmpty, let mw = result.molecularWeight { current.molecularWeight = mw } if current.physicalState.isEmpty, let ps = result.physicalState { current.physicalState = ps } // Build debug string showing both names 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" } // PubChem result is definitive — stop AI from overriding aiNameConfidence = 100 aiSatisfied = true } else { debugPubChemName = "failed for \(cas)" debugArbitration = "—" pubChemFailed = true // allow AI to keep running as fallback } isLookingUpPubChem = false } } } 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)%)" // Apply name if: confident enough AND (no name yet OR higher confidence than previous AI guess) if !result.chemicalName.isEmpty, conf >= aiNameConfidenceThreshold, (current.chemicalName.isEmpty || conf > aiNameConfidence) { current.chemicalName = result.chemicalName previewFields["name"] = result.chemicalName aiNameConfidence = conf } // For secondary fields: AI can freely write or update values unless // the regex accumulator has already confirmed them (2+ OCR votes = more reliable). if !result.physicalState.isEmpty { current.physicalState = result.physicalState } if !lockedByRegex.contains("vendor"), !result.vendor.isEmpty { current.vendor = result.vendor previewFields["vendor"] = result.vendor } // CAS: only fill from AI if regex hasn't seen it — regex is more reliable for structured numbers 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 } } private func applyLocked(key: String, value: String) { // Mark field as regex-confirmed. AI will not overwrite these. 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 } } 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 containerTypeSelected = false showContainerTypePicker = false aiTriggered = false aiNameConfidence = 0 lastAITriggerTime = .distantPast aiSatisfied = false allOCRLines = [] debugBarcode = "—" debugOCRLineCount = 0 debugPubChemName = "—" debugAIName = "—" debugArbitration = "—" debugAIQueryCount = 0 isLookingUpPubChem = false } func startScanning() { resetScanState() isScanning = true showContainerTypePicker = true } func stopScanning() { isScanning = false let snapshot = current scannedItems.append(snapshot) let appendedIndex = scannedItems.count - 1 // Cancel any in-flight tasks before starting the post-stop AI pass pubChemTask?.cancel() pubChemTask = nil // Final AI pass — update the already-appended item when it completes if snapshot.chemicalName.isEmpty, !allOCRLines.isEmpty { let lines = Array(allOCRLines) let lockedSnapshot = lockedByRegex // capture by value — resetScanState clears the original 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" } } else { debugAIName = "iOS 18.1+ req" } isRunningAI = false } } } func finishSession() { isScanning = false showReview = true } private func resetAccumulators() { accumulators.values.forEach { $0.reset() } } } // MARK: - ScanView (main) struct ScanView: View { @Environment(\.dismiss) private var dismiss @State private var viewModel = LabelScannerViewModel() var body: some View { NavigationStack { ZStack { // Camera feed (always live) 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.") ) } // Overlay VStack(spacing: 0) { // Banner if viewModel.isScanning { ScanBanner(viewModel: viewModel) .transition(.move(edge: .top).combined(with: .opacity)) } Spacer() // Bottom controls 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) } } } } .task { await viewModel.loadProfile() } .sheet(isPresented: $viewModel.showReview) { ScanReviewView(viewModel: viewModel, onDone: { dismiss() }) } .sheet(isPresented: $viewModel.showContainerTypePicker) { ContainerTypePickerSheet(viewModel: viewModel) .presentationDetents([.medium]) .presentationDragIndicator(.visible) } .animation(.easeInOut(duration: 0.3), value: viewModel.isScanning) } } // MARK: - Live scanner UIViewControllerRepresentable struct LiveScannerView: UIViewControllerRepresentable { let viewModel: LabelScannerViewModel func makeUIViewController(context: Context) -> DataScannerViewController { let scanner = DataScannerViewController( recognizedDataTypes: [.text(), .barcode()], qualityLevel: .accurate, recognizesMultipleItems: true, isHighFrameRateTrackingEnabled: false, isHighlightingEnabled: false // we draw our own banner, no overlays needed ) scanner.delegate = context.coordinator context.coordinator.viewModel = viewModel try? scanner.startScanning() return scanner } func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator() } @MainActor final class Coordinator: NSObject, DataScannerViewControllerDelegate, @unchecked Sendable { var viewModel: LabelScannerViewModel? private var lastProcessed = Date.distantPast func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) { // Process immediately when new items are first recognized (catches barcodes that don't trigger didUpdate) processItems(allItems, in: dataScanner, forceBarcode: true) } func dataScanner(_ dataScanner: DataScannerViewController, didUpdate updatedItems: [RecognizedItem], allItems: [RecognizedItem]) { // Throttle to ~2 frames/sec to avoid hammering let now = Date() guard now.timeIntervalSince(lastProcessed) >= 0.5 else { return } lastProcessed = now processItems(allItems, in: dataScanner, forceBarcode: false) } private func processItems(_ allItems: [RecognizedItem], in dataScanner: DataScannerViewController, forceBarcode: Bool) { var texts: [String] = [] var barcode: String? // raw payload stored in the item var barcodeDebug: String? // includes symbology, shown on screen only 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 } } // Skip if no barcode found and this was only called for barcode detection if forceBarcode && barcode == nil { return } // Capture thumbnail 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) } } } } // MARK: - Container type picker sheet 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) } } } } // MARK: - Scan banner struct ScanBanner: View { @Bindable var viewModel: LabelScannerViewModel var body: some View { VStack(alignment: .leading, spacing: 0) { // Header 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)) // Fields grid 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) // Debug strip (collapsible) 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) } } .padding(.horizontal, 16) .padding(.vertical, 6) .transition(.opacity.combined(with: .move(edge: .top))) } // Fill slider 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) } .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) } } 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) } .frame(maxWidth: .infinity, alignment: .leading) } } // MARK: - Bottom bar struct ScanBottomBar: View { let viewModel: LabelScannerViewModel var body: some View { HStack(spacing: 24) { // Scanned count badge VStack(spacing: 2) { Text("\(viewModel.scannedItems.count)") .font(.title2.bold()) .foregroundStyle(.white) Text("saved") .font(.caption2) .foregroundStyle(.white.opacity(0.7)) } .frame(width: 52) // Start / Stop button Button { if viewModel.isScanning { viewModel.stopScanning() } else { viewModel.startScanning() } } 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) } } .shadow(radius: 4) // Hint VStack(spacing: 2) { Text(viewModel.isScanning ? "Tap stop\nwhen done" : "Tap play\nto 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)) } } // MARK: - Review view struct ScanReviewView: View { @Bindable var viewModel: LabelScannerViewModel let onDone: () -> Void @State private var isSavingAll = false @State private var saveError: String? @State private var editingItem: ScannedItem? @State private var savedCount = 0 private let chemicalsClient = ChemicalsClient() 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") } } } if let error = saveError { Section { Text(error).foregroundStyle(.red).font(.footnote) } } 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) } } .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 } editingItem = nil } } } private func saveAll() async { isSavingAll = true saveError = nil savedCount = 0 var failed = 0 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 } } isSavingAll = false if failed > 0 { saveError = "\(failed) item(s) failed to save. The rest were saved successfully." } else { onDone() } } } // MARK: - Scanned item row (review list) private struct ScannedItemRow: View { let item: ScannedItem 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) } } Spacer() Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(.tertiary) } .padding(.vertical, 4) } } // MARK: - Edit a single scanned item 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 } var body: some View { 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) } } 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) } } } } } private struct LabeledTextField: View { let label: String @Binding var text: String init(_ label: String, text: Binding) { self.label = label self._text = text } var body: some View { HStack { Text(label).foregroundStyle(Color(UIColor.label)) Spacer(minLength: 8) TextField(label, text: $text) .multilineTextAlignment(.trailing) .foregroundStyle(Color(UIColor.secondaryLabel)) } } }