diff --git a/LabWise/AIvsPubChem.md b/LabWise/AIvsPubChem.md new file mode 100644 index 0000000..97cc3c5 --- /dev/null +++ b/LabWise/AIvsPubChem.md @@ -0,0 +1,24 @@ +CAS locked in → PubChem lookup fires + ↓ + ┌────────────┴────────────┐ + PubChem has name? No name + ↓ ↓ + AI has name? keep AI name (if any) + ┌───────┴───────┐ aiSatisfied = !aiName.isEmpty + Yes No + ↓ ↓ + arbitrate use PubChem name directly + ↓ aiSatisfied = true + isSameCompound? + ┌────┴────┐ + Yes No + ↓ ↓ + labelIsCommonName? trust PubChem (CAS is reliable) + ┌────┴────┐ + Yes No + ↓ ↓ + use use + label PubChem + name name + ("CHAPS") +aiSatisfied = true → no more AI re-triggers diff --git a/LabWise/InventoryView.swift b/LabWise/InventoryView.swift index fe237f5..4670827 100644 --- a/LabWise/InventoryView.swift +++ b/LabWise/InventoryView.swift @@ -108,6 +108,9 @@ struct InventoryView: View { } case .scan: ScanView() + .onDisappear { + Task { await viewModel.loadChemicals() } + } } } .task { diff --git a/LabWise/ScanView.swift b/LabWise/ScanView.swift index 61d43d9..d7b1e3f 100644 --- a/LabWise/ScanView.swift +++ b/LabWise/ScanView.swift @@ -1,70 +1,229 @@ import SwiftUI import VisionKit +import Vision import LabWiseKit - -// MARK: - Scan stages - -enum ScanStage { - case camera - case analyzing - case review -} - -// MARK: - Extracted label data (Foundation Models @Generable output) - import FoundationModels +// MARK: - On-device AI label extraction + @Generable -struct ExtractedLabelData { - @Guide(description: "CAS registry number, e.g. 7732-18-5") - var casNumber: String - - @Guide(description: "Full chemical name") +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: "Chemical formula, e.g. H2O") - var chemicalFormula: String - - @Guide(description: "Molecular weight with unit, e.g. 18.015 g/mol") - var molecularWeight: String - - @Guide(description: "Vendor or manufacturer name") - var vendor: String - - @Guide(description: "Catalog number from vendor") - var catalogNumber: String - - @Guide(description: "Lot number") - var lotNumber: String - - @Guide(description: "Concentration, e.g. 1M, 98%") - var concentration: String - - @Guide(description: "Expiration date in ISO format YYYY-MM-DD if found") - var expirationDate: String - - @Guide(description: "Physical state: liquid, solid, or gas") + @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: "Storage device inferred from label: flammable cabinet, fridge, freezer, ambient, etc.") - var storageDevice: String - - @Guide(description: "Container type: bottle, cylinder, drum, vial, etc.") - var containerType: 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 } -// MARK: - ScanViewModel +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. \ + The date may use numeric months (06/2018), abbreviated month names (Jun), or full month names. \ + 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. + """ +} -@Observable -final class ScanViewModel { - var stage: ScanStage = .camera - var capturedImage: UIImage? - var capturedTexts: [String] = [] - var scannedBarcode: String? +private let aiNameConfidenceThreshold = 60 // minimum confidence to apply AI-extracted name - // Extracted fields - var casNumber = "" +// 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: - Vision container classification + +/// Vision label identifier fragments → friendly container name. +/// Ordered from most specific to least specific. +private let containerVisionMappings: [(fragment: String, name: String)] = [ + ("vial", "Vial"), + ("ampoule", "Ampoule"), + ("ampule", "Ampoule"), + ("flask", "Flask"), + ("beaker", "Beaker"), + ("tube", "Tube"), + ("canister", "Canister"), + ("drum", "Drum"), + ("barrel", "Drum"), + ("jar", "Jar"), + ("can", "Can"), + ("bag", "Bag"), + ("bottle", "Bottle"), // generic — caller refines to Glass/Plastic if possible + ("container", "Bottle"), + ("jug", "Bottle"), +] + +/// Minimum confidence for a Vision label to be considered a reliable container identification. +private let containerConfidenceThreshold: Float = 0.35 + +/// Runs Vision's on-device image classifier on a thumbnail and returns a +/// human-friendly container type string, or nil if nothing was identified with sufficient confidence. +private func classifyContainerType(from image: UIImage) async -> String? { + guard let cgImage = image.cgImage else { return nil } + do { + let results = try await ClassifyImageRequest() + .perform(on: cgImage) + .filter { $0.confidence >= containerConfidenceThreshold } + .sorted { $0.confidence > $1.confidence } + + for observation in results { + let id = observation.identifier.lowercased() + for mapping in containerVisionMappings { + guard id.contains(mapping.fragment) else { continue } + // For generic "Bottle" matches, require a higher bar and try to + // distinguish glass vs plastic from co-occurring labels. + if mapping.name == "Bottle" { + guard observation.confidence >= 0.5 else { return nil } + let allIDs = results.map { $0.identifier.lowercased() }.joined(separator: " ") + if allIDs.contains("plastic") { return "Plastic Bottle" } + if allIDs.contains("glass") { return "Glass Bottle" } + // Can't distinguish material — don't guess + return nil + } + return mapping.name + } + } + } catch { + // Vision failure is non-fatal + } + return nil +} + + + +@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 = "" @@ -73,25 +232,317 @@ final class ScanViewModel { var concentration = "" var expirationDate = "" var physicalState = "" - var storageDevice = "" - var numberOfContainers = "1" + var storageDevice = "Glass Bottle" var amountPerContainer = "" var unitOfMeasure = "" + var barcode: String? var percentageFull: Double = 50 + var thumbnail: UIImage? +} - // Pre-populated from profile +// 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 = "" - var analysisError: String? - var isSaving = false - var saveError: String? - var didSave = false + // 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 + // Set to true once Vision has classified the container shape for this scan. + private var containerClassified = 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 chemicalsClient = ChemicalsClient() private let profileClient = ProfileClient() func loadProfile() async { @@ -103,507 +554,956 @@ final class ScanViewModel { } } - func analyzeTexts() async { - stage = .analyzing - analysisError = nil - - guard SystemLanguageModel.default.isAvailable else { - // Fall through to review with empty fields - stage = .review + // 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 } - do { - let combinedText = capturedTexts.joined(separator: "\n") - let session = LanguageModelSession(instructions: """ - You are a chemical label OCR assistant. Extract structured fields from the raw OCR text of a chemical reagent label. - Return only the fields you can identify. Leave fields empty string if not found. - For storageDevice infer from hazard symbols: FLAMMABLE → flammable cabinet, KEEP REFRIGERATED → fridge, KEEP FROZEN → freezer. - """) - let response = try await session.respond( - to: "Extract fields from this chemical label text:\n\n\(combinedText)", - generating: ExtractedLabelData.self - ) - let data = response.content - await MainActor.run { - casNumber = data.casNumber - chemicalName = data.chemicalName - chemicalFormula = data.chemicalFormula - molecularWeight = data.molecularWeight - vendor = data.vendor - catalogNumber = data.catalogNumber - lotNumber = data.lotNumber - concentration = data.concentration - expirationDate = data.expirationDate - physicalState = data.physicalState - storageDevice = data.storageDevice - } - } catch { - await MainActor.run { - analysisError = "Label analysis failed: \(error.localizedDescription)" + // Snapshot for slider — also trigger container classification on first thumbnail + if let thumb = thumbnail, current.thumbnail == nil { + current.thumbnail = thumb + if !containerClassified { + containerClassified = true + let generation = scanGeneration + Task { + if let containerType = await classifyContainerType(from: thumb), + generation == scanGeneration { + current.storageDevice = containerType + previewFields["container"] = containerType + } + } } } - await MainActor.run { stage = .review } - } + // Debug: track OCR line count every frame + debugOCRLineCount = texts.count - var missingRequiredFields: [String] { - var missing: [String] = [] - if piFirstName.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("PI First Name") } - if bldgCode.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("Building Code") } - if lab.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("Lab") } - if storageLocation.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("Storage Location") } - if chemicalName.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("Chemical Name") } - if casNumber.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("CAS Number") } - return missing - } + // Buffer barcode even before scanning starts so we don't miss a quick read + if let bc = barcode { + pendingBarcode = bc + debugBarcode = barcodeDebug ?? bc + } - func save() async { - isSaving = true - saveError = nil - defer { isSaving = false } - do { - let body = ChemicalCreateBody( - piFirstName: piFirstName, - physicalState: physicalState.isEmpty ? "unknown" : physicalState, - chemicalName: chemicalName, - bldgCode: bldgCode, - lab: lab, - storageLocation: storageLocation, - storageDevice: storageDevice.isEmpty ? "unknown" : storageDevice, - numberOfContainers: numberOfContainers.isEmpty ? "1" : numberOfContainers, - amountPerContainer: amountPerContainer.isEmpty ? "unknown" : amountPerContainer, - unitOfMeasure: unitOfMeasure.isEmpty ? "unknown" : unitOfMeasure, - casNumber: casNumber, - chemicalFormula: chemicalFormula.isEmpty ? nil : chemicalFormula, - molecularWeight: molecularWeight.isEmpty ? nil : molecularWeight, - vendor: vendor.isEmpty ? nil : vendor, - catalogNumber: catalogNumber.isEmpty ? nil : catalogNumber, - lotNumber: lotNumber.isEmpty ? nil : lotNumber, - expirationDate: expirationDate.isEmpty ? nil : expirationDate, - concentration: concentration.isEmpty ? nil : concentration, - percentageFull: percentageFull, - barcode: scannedBarcode, - contact: contact.isEmpty ? nil : contact - ) - _ = try await chemicalsClient.create(body) - await MainActor.run { didSave = true } - } catch { - await MainActor.run { saveError = "Failed to save: \(error.localizedDescription)" } + // 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 + } } } - func reset() { - stage = .camera - capturedImage = nil - capturedTexts = [] - scannedBarcode = nil - casNumber = ""; chemicalName = ""; chemicalFormula = "" - molecularWeight = ""; vendor = ""; catalogNumber = "" - lotNumber = ""; concentration = ""; expirationDate = "" - physicalState = ""; storageDevice = ""; storageLocation = "" - numberOfContainers = "1"; amountPerContainer = ""; unitOfMeasure = "" - percentageFull = 50; analysisError = nil; didSave = false; saveError = nil + 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 + containerClassified = 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 + } + + 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: - Main ScanView +// MARK: - ScanView (main) struct ScanView: View { - @State private var viewModel = ScanViewModel() - @State private var showChemicalsList = false + @Environment(\.dismiss) private var dismiss + @State private var viewModel = LabelScannerViewModel() var body: some View { NavigationStack { - Group { - switch viewModel.stage { - case .camera: - DataScannerWrapperView(viewModel: viewModel) - case .analyzing: - AnalyzingView() - case .review: - ReviewFormView(viewModel: viewModel, onSaved: { - showChemicalsList = true - }) + 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) } } - .navigationTitle(stageName) .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.hidden, for: .navigationBar) .toolbar { - if viewModel.stage != .camera { - ToolbarItem(placement: .topBarLeading) { - Button("Rescan") { viewModel.reset() } + 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() } - .onChange(of: viewModel.didSave) { _, saved in - if saved { showChemicalsList = true } - } - } - - private var stageName: String { - switch viewModel.stage { - case .camera: return "Scan Label" - case .analyzing: return "Analysing..." - case .review: return "Review" + .sheet(isPresented: $viewModel.showReview) { + ScanReviewView(viewModel: viewModel, onDone: { dismiss() }) } + .animation(.easeInOut(duration: 0.3), value: viewModel.isScanning) } } -// MARK: - Analysing placeholder +// MARK: - Live scanner UIViewControllerRepresentable -struct AnalyzingView: View { - var body: some View { - VStack(spacing: 20) { - ProgressView() - .scaleEffect(1.5) - Text("Analysing label...") - .font(.headline) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - -// MARK: - DataScanner wrapper - -struct DataScannerWrapperView: View { - let viewModel: ScanViewModel - @State private var recognizedTexts: [RecognizedItem] = [] - @State private var showUnsupported = false - - var body: some View { - ZStack(alignment: .bottom) { - if DataScannerViewController.isSupported && DataScannerViewController.isAvailable { - DataScannerRepresentable( - recognizedItems: $recognizedTexts, - onCapture: { image, texts, barcode in - viewModel.capturedImage = image - viewModel.capturedTexts = texts - viewModel.scannedBarcode = barcode - Task { await viewModel.analyzeTexts() } - } - ) - .ignoresSafeArea() - - // Capture button - VStack { - Spacer() - CaptureButton { - // Capture handled inside representable via button tap callback - } - .padding(.bottom, 40) - } - } else { - ContentUnavailableView( - "Scanner Unavailable", - systemImage: "camera.slash", - description: Text("This device does not support data scanning.") - ) - } - } - } -} - -struct CaptureButton: View { - let action: () -> Void - - var body: some View { - Button(action: action) { - ZStack { - Circle() - .fill(.white) - .frame(width: 72, height: 72) - Circle() - .strokeBorder(.white, lineWidth: 4) - .frame(width: 84, height: 84) - } - } - } -} - -// MARK: - UIViewControllerRepresentable for DataScanner - -struct DataScannerRepresentable: UIViewControllerRepresentable { - @Binding var recognizedItems: [RecognizedItem] - let onCapture: (UIImage, [String], String?) -> Void +struct LiveScannerView: UIViewControllerRepresentable { + let viewModel: LabelScannerViewModel func makeUIViewController(context: Context) -> DataScannerViewController { let scanner = DataScannerViewController( recognizedDataTypes: [.text(), .barcode()], - qualityLevel: .balanced, + qualityLevel: .accurate, recognizesMultipleItems: true, - isHighFrameRateTrackingEnabled: true, - isHighlightingEnabled: true + isHighFrameRateTrackingEnabled: false, + isHighlightingEnabled: false // we draw our own banner, no overlays needed ) scanner.delegate = context.coordinator - context.coordinator.scanner = scanner - context.coordinator.onCapture = onCapture + context.coordinator.viewModel = viewModel try? scanner.startScanning() return scanner } func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {} - func makeCoordinator() -> Coordinator { - Coordinator(recognizedItems: $recognizedItems) - } + func makeCoordinator() -> Coordinator { Coordinator() } - class Coordinator: NSObject, DataScannerViewControllerDelegate { - @Binding var recognizedItems: [RecognizedItem] - weak var scanner: DataScannerViewController? - var onCapture: ((UIImage, [String], String?) -> Void)? - - init(recognizedItems: Binding<[RecognizedItem]>) { - _recognizedItems = recognizedItems - } + @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]) { - recognizedItems = allItems + // 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]) { - recognizedItems = allItems + // 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) } - func dataScanner(_ dataScanner: DataScannerViewController, didRemove removedItems: [RecognizedItem], allItems: [RecognizedItem]) { - recognizedItems = allItems - } - - func captureCurrentFrame() { - guard let scanner else { return } - - // Collect all recognized text and barcodes + private func processItems(_ allItems: [RecognizedItem], in dataScanner: DataScannerViewController, forceBarcode: Bool) { var texts: [String] = [] - var barcode: String? - - for item in recognizedItems { + 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 .text(let t): texts.append(t.transcript) case .barcode(let b): - if let payload = b.payloadStringValue { - barcode = payload - texts.append(payload) + if let p = b.payloadStringValue { + barcode = p + let sym = b.observation.symbology.rawValue.replacingOccurrences(of: "VNBarcodeSymbology", with: "") + barcodeDebug = "[\(sym)] \(p)" } - default: - break + default: break } } - // Capture a snapshot of the current frame - let renderer = UIGraphicsImageRenderer(bounds: scanner.view.bounds) - let image = renderer.image { ctx in - scanner.view.layer.render(in: ctx.cgContext) - } + // Skip if no barcode found and this was only called for barcode detection + if forceBarcode && barcode == nil { return } - onCapture?(image, texts, barcode) + // 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: - ReviewFormView +// MARK: - Scan banner -struct ReviewFormView: View { - @Bindable var viewModel: ScanViewModel - let onSaved: () -> Void +struct ScanBanner: View { + @Bindable var viewModel: LabelScannerViewModel var body: some View { - ScrollView { - VStack(spacing: 0) { - // Captured frame + percentage slider - if let image = viewModel.capturedImage { - CapturedImageWithSlider(image: image, percentageFull: $viewModel.percentageFull) - .frame(height: 260) - .clipped() + 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) } - if let error = viewModel.analysisError { - Text(error) - .foregroundStyle(.orange) - .font(.footnote) - .padding(.horizontal) - .padding(.top, 8) - } - - Form { - // Required fields section - Section { - ReviewField("Chemical Name", text: $viewModel.chemicalName, required: true, missing: viewModel.missingRequiredFields.contains("Chemical Name")) - ReviewField("CAS Number", text: $viewModel.casNumber, required: true, missing: viewModel.missingRequiredFields.contains("CAS Number")) - ReviewField("Physical State", text: $viewModel.physicalState) - ReviewField("Storage Device", text: $viewModel.storageDevice) - } header: { - Text("Identity") - } - - Section { - ReviewField("PI First Name", text: $viewModel.piFirstName, required: true, missing: viewModel.missingRequiredFields.contains("PI First Name")) - ReviewField("Building Code", text: $viewModel.bldgCode, required: true, missing: viewModel.missingRequiredFields.contains("Building Code")) - ReviewField("Lab", text: $viewModel.lab, required: true, missing: viewModel.missingRequiredFields.contains("Lab")) - ReviewField("Storage Location", text: $viewModel.storageLocation, required: true, missing: viewModel.missingRequiredFields.contains("Storage Location")) - } header: { - Text("Location") - } - - Section { - ReviewField("# Containers", text: $viewModel.numberOfContainers) - ReviewField("Amount / Container", text: $viewModel.amountPerContainer) - ReviewField("Unit of Measure", text: $viewModel.unitOfMeasure) - } header: { - Text("Quantity") - } - - Section { - ReviewField("Formula", text: $viewModel.chemicalFormula) - ReviewField("Molecular Weight", text: $viewModel.molecularWeight) - ReviewField("Concentration", text: $viewModel.concentration) - ReviewField("Vendor", text: $viewModel.vendor) - ReviewField("Catalog #", text: $viewModel.catalogNumber) - ReviewField("Lot #", text: $viewModel.lotNumber) - ReviewField("Expiration Date", text: $viewModel.expirationDate) - } header: { - Text("Details") - } - - Section { - if let saveError = viewModel.saveError { - Text(saveError) - .foregroundStyle(.red) - .font(.footnote) - } - Button { - Task { await viewModel.save() } - } label: { - if viewModel.isSaving { - ProgressView() - .frame(maxWidth: .infinity) - } else { - Text("Save Chemical") - .frame(maxWidth: .infinity) - .fontWeight(.semibold) + 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") } } - .buttonStyle(.borderedProminent) - .disabled(!viewModel.missingRequiredFields.isEmpty || viewModel.isSaving) - } + } - if !viewModel.missingRequiredFields.isEmpty { - Section { - Text("Required: \(viewModel.missingRequiredFields.joined(separator: ", "))") - .foregroundStyle(.red) - .font(.footnote) + 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) } - .frame(minHeight: 800) - .scrollDisabled(true) } - } - } -} - -// MARK: - Captured image with vertical % slider - -struct CapturedImageWithSlider: View { - let image: UIImage - @Binding var percentageFull: Double - - var body: some View { - ZStack(alignment: .trailing) { - Image(uiImage: image) - .resizable() - .scaledToFill() - - // Dim overlay - Color.black.opacity(0.2) - - // Vertical slider on right edge - HStack { - Spacer() - VStack(spacing: 8) { - Text("\(Int(percentageFull))%") - .font(.caption.bold()) - .foregroundStyle(.white) - .shadow(radius: 2) - - VerticalSlider(value: $percentageFull, range: 0...100) - .frame(width: 32, height: 180) - } - .padding(.trailing, 12) - } - } - } -} - -// MARK: - Vertical slider - -struct VerticalSlider: View { - @Binding var value: Double - let range: ClosedRange - - @GestureState private var isDragging = false - - var body: some View { - GeometryReader { geo in - ZStack(alignment: .bottom) { - // Track - Capsule() - .fill(Color.white.opacity(0.3)) - - // Fill - Capsule() - .fill(Color.white.opacity(0.8)) - .frame(height: geo.size.height * fillFraction) - - // Thumb - Circle() - .fill(.white) - .frame(width: 28, height: 28) - .shadow(radius: 3) - .offset(y: -geo.size.height * fillFraction + 14) - } - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { drag in - let fraction = 1 - (drag.location.y / geo.size.height) - let clamped = max(range.lowerBound, min(range.upperBound, fraction * (range.upperBound - range.lowerBound) + range.lowerBound)) - value = clamped + .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 var fillFraction: Double { - (value - range.lowerBound) / (range.upperBound - range.lowerBound) + 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: - ReviewField helper +// MARK: - Scanned item row (review list) -struct ReviewField: View { +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 - var required: Bool = false - var missing: Bool = false - - init(_ label: String, text: Binding, required: Bool = false, missing: Bool = false) { + init(_ label: String, text: Binding) { self.label = label self._text = text - self.required = required - self.missing = missing } - var body: some View { HStack { - Text(label + (required ? " *" : "")) - .foregroundStyle(missing ? .red : .primary) - .frame(minWidth: 120, alignment: .leading) + Text(label).foregroundStyle(Color(UIColor.label)) + Spacer(minLength: 8) TextField(label, text: $text) .multilineTextAlignment(.trailing) + .foregroundStyle(Color(UIColor.secondaryLabel)) } - .listRowBackground(missing ? Color.red.opacity(0.05) : Color(uiColor: .secondarySystemGroupedBackground)) } }