Files
LabWiseiOS/LabWise/ScanView.swift

1499 lines
64 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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"
]
/// 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 (19002099)
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<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 : ""
}
// 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<String> = []
// 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<Void, Never>?
// On-device AI extraction
var isRunningAI = false
private var aiTask: Task<Void, Never>?
private var allOCRLines: Set<String> = [] // 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<String>) {
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))
}
}
}