1499 lines
64 KiB
Swift
1499 lines
64 KiB
Swift
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 (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<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))
|
||
}
|
||
}
|
||
}
|