Files
LabWiseiOS/LabWise/ScanView.swift

1499 lines
64 KiB
Swift
Raw Normal View History

import SwiftUI
import VisionKit
2026-03-22 01:35:53 -05:00
import Vision
import LabWiseKit
2026-03-22 01:35:53 -05:00
import FoundationModels
2026-03-22 01:35:53 -05:00
// MARK: - On-device AI label extraction
2026-03-22 01:35:53 -05:00
@Generable
struct LabelExtraction {
@Guide(description: "The product or chemical name as it appears on the label, e.g. 'Neutralization/Binding Buffer (S3)' or 'Sodium Chloride'. Empty string if not found.")
var chemicalName: String
@Guide(description: "Physical state: 'Solid', 'Liquid', or 'Gas'. Infer from context clues like 'solution', 'buffer', 'powder', 'crystals'. Empty string if unknown.")
var physicalState: String
@Guide(description: "Vendor or manufacturer name, e.g. 'Sigma-Aldrich', 'Fisher Scientific'. Empty string if not found.")
var vendor: String
@Guide(description: "CAS registry number in the format XXXXXXX-XX-X, e.g. '7647-14-5'. Empty string if not found.")
var casNumber: String
@Guide(description: "Lot number or batch number, e.g. 'SLBH4570V', '123456'. Usually preceded by 'Lot', 'Lot#', or 'Batch'. Empty string if not found.")
var lotNumber: String
@Guide(description: "Expiration or use-by date found explicitly in the label text. Normalize to YYYY-MM-DD if possible, otherwise return as-is. Return empty string if no expiration keyword and date are present in the text — do not guess.")
var expirationDate: String
@Guide(description: "The amount or quantity of contents, as a number only, e.g. '5', '100', '2.5'. Empty string if not found.")
var amount: String
@Guide(description: "The unit for the amount, e.g. 'g', 'mL', 'kg', 'mg', 'L'. Empty string if not found.")
var unit: String
@Guide(description: "Catalog or product number, e.g. 'P0662-500G', 'S7653'. Empty string if not found.")
var catalogNumber: String
@Guide(description: "Your confidence in the chemical name extraction, from 0 to 100. Use 90+ only when the name is explicitly printed as a clear product/chemical name. Use 50-89 when inferred. Use below 50 if the text is ambiguous or noisy.", .range(0...100))
var nameConfidence: Int
}
2026-03-22 01:35:53 -05:00
private let labelExtractionInstructions = Instructions {
"""
You are a laboratory chemical label parser. Given raw OCR text lines from a reagent bottle label, \
extract only the fields that are explicitly present in the text. \
Do NOT invent, infer, or hallucinate values if a field is not clearly visible in the provided text, return an empty string for it. \
For expiration dates: look for keywords like EXP, EXPIRY, EXPIRATION, USE BY, BEST BEFORE followed by a date. \
For amount and unit: look for a number directly followed by a unit like g, gm, mg, kg, mL, L, mol. \
Set nameConfidence honestly: 90+ only when the product name is explicitly and clearly printed; 50-89 when inferred; below 50 when guessing.
"""
}
2026-03-22 01:35:53 -05:00
private let aiNameConfidenceThreshold = 60 // minimum confidence to apply AI-extracted name
2026-03-22 01:35:53 -05:00
// MARK: - Name arbitration
2026-03-22 01:35:53 -05:00
@Generable(description: "Result of comparing a chemical's IUPAC/systematic name against its label name")
struct NameArbitration {
@Guide(description: "True if the labelName is a common name, abbreviation, or trade name that refers to the same compound as iupacName. False if they seem unrelated.")
var isSameCompound: Bool
@Guide(description: "True if labelName is a common/abbreviated name and iupacName is the corresponding systematic/IUPAC name for it. False if labelName is also a systematic or verbose name.")
var labelIsCommonName: Bool
@Guide(description: "The name to display: if labelIsCommonName is true use the labelName, otherwise use the iupacName.")
var preferredName: String
}
2026-03-22 01:35:53 -05:00
private let nameArbitrationInstructions = Instructions {
"""
You are a chemistry expert. You will be given two names for a chemical compound: \
an IUPAC/systematic name from a database, and a name read from a product label. \
Determine if they refer to the same compound, and whether the label name is a \
common name or abbreviation (like 'CHAPS', 'HEPES', 'SDS', 'Ethanol') versus a \
systematic/verbose name. Prefer the common name when it is clearly an established \
abbreviation or trade name used in laboratory settings.
"""
}
2026-03-22 01:35:53 -05:00
@available(iOS 18.1, *)
private func arbitrateNames(iupacName: String, labelName: String) async -> NameArbitration? {
let model = SystemLanguageModel.default
guard case .available = model.availability else { return nil }
let session = LanguageModelSession(instructions: nameArbitrationInstructions)
let prompt = "IUPAC name: \(iupacName)\nLabel name: \(labelName)"
return try? await session.respond(to: prompt, generating: NameArbitration.self).content
}
2026-03-22 01:35:53 -05:00
/// 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)
}
2026-03-22 01:35:53 -05:00
/// Validates and sanitizes a date string. Returns empty string if it doesn't
/// look like a real date (e.g. "0000-00-00", "1900-01-01", "9999-99-99").
private func aiSanitizeDate(_ value: String) -> String {
let s = aiSanitize(value)
guard !s.isEmpty else { return "" }
// Reject zero/placeholder dates
let stripped = s.replacingOccurrences(of: "-", with: "")
.replacingOccurrences(of: "/", with: "")
.replacingOccurrences(of: "0", with: "")
if stripped.isEmpty { return "" } // all zeros after stripping separators
// Must contain a plausible 4-digit year (19002099)
let yearRegex = try? NSRegularExpression(pattern: #"(19|20)\d{2}"#)
let range = NSRange(s.startIndex..., in: s)
guard yearRegex?.firstMatch(in: s, range: range) != nil else { return "" }
return s
}
2026-03-22 01:35:53 -05:00
/// 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
}
2026-03-22 01:35:53 -05:00
/// 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
}
2026-03-22 01:35:53 -05:00
/// 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 : ""
}
2026-03-22 02:36:24 -05:00
// MARK: - Container type options
2026-03-22 01:35:53 -05:00
2026-03-22 02:36:24 -05:00
/// 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"
]
2026-03-22 01:35:53 -05:00
@available(iOS 18.1, *)
private func onDeviceLabelLookup(ocrLines: [String]) async -> LabelExtraction? {
let model = SystemLanguageModel.default
guard case .available = model.availability else { return nil }
let session = LanguageModelSession(instructions: labelExtractionInstructions)
let joined = ocrLines.joined(separator: "\n")
let prompt = "Label text:\n\(joined)"
guard var result = try? await session.respond(to: prompt, generating: LabelExtraction.self).content
else { return nil }
// 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
}
2026-03-22 01:35:53 -05:00
// MARK: - Scanned item (one bottle's worth of data)
2026-03-22 01:35:53 -05:00
struct ScannedItem: Identifiable {
let id = UUID()
var chemicalName = ""
2026-03-22 01:35:53 -05:00
var casNumber = ""
var chemicalFormula = ""
var molecularWeight = ""
var vendor = ""
var catalogNumber = ""
var lotNumber = ""
var concentration = ""
var expirationDate = ""
var physicalState = ""
2026-03-22 01:35:53 -05:00
var storageDevice = "Glass Bottle"
var amountPerContainer = ""
var unitOfMeasure = ""
2026-03-22 01:35:53 -05:00
var barcode: String?
var percentageFull: Double = 50
2026-03-22 01:35:53 -05:00
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
2026-03-22 01:35:53 -05:00
@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 = ""
2026-03-22 01:35:53 -05:00
// 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
2026-03-22 02:36:24 -05:00
// 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
2026-03-22 01:35:53 -05:00
// 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
2026-03-22 02:36:24 -05:00
2026-03-22 01:35:53 -05:00
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 ?? ""
}
}
2026-03-22 01:35:53 -05:00
// 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
}
2026-03-22 02:36:24 -05:00
// Keep thumbnail fresh every frame (used by the fill slider preview)
if let thumb = thumbnail {
2026-03-22 01:35:53 -05:00
current.thumbnail = thumb
}
2026-03-22 01:35:53 -05:00
// Debug: track OCR line count every frame
debugOCRLineCount = texts.count
2026-03-22 01:35:53 -05:00
// Buffer barcode even before scanning starts so we don't miss a quick read
if let bc = barcode {
pendingBarcode = bc
debugBarcode = barcodeDebug ?? bc
}
2026-03-22 01:35:53 -05:00
// 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
}
}
2026-03-22 01:35:53 -05:00
// 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)
}
2026-03-22 01:35:53 -05:00
// 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
}
2026-03-22 01:35:53 -05:00
if current.molecularWeight.isEmpty, let mw = result.molecularWeight {
current.molecularWeight = mw
}
if current.physicalState.isEmpty, let ps = result.physicalState {
current.physicalState = ps
}
// 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
}
2026-03-22 01:35:53 -05:00
isLookingUpPubChem = false
}
}
}
2026-03-22 01:35:53 -05:00
private func triggerAIExtraction() {
aiTriggered = true
aiTask?.cancel()
aiTask = nil
let lines = Array(allOCRLines)
lastAITriggerTime = Date()
debugAIQueryCount += 1
debugAIName = "querying #\(debugAIQueryCount)"
let generation = scanGeneration
aiTask = Task {
isRunningAI = true
if #available(iOS 18.1, *) {
if let result = await onDeviceLabelLookup(ocrLines: lines),
generation == scanGeneration {
let conf = result.nameConfidence
let extracted = result.chemicalName.isEmpty ? "" : result.chemicalName
debugAIName = "\(extracted) (\(conf)%)"
// 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
}
}
2026-03-22 01:35:53 -05:00
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
}
}
2026-03-22 01:35:53 -05:00
private func resetScanState() {
scanGeneration += 1
resetAccumulators()
current = ScannedItem()
previewFields = [:]
lockedByRegex = []
barcodeHandled = false
pendingBarcode = nil
pubChemTask?.cancel()
pubChemTask = nil
lastPubChemCAS = ""
pubChemFailed = false
aiTask?.cancel()
aiTask = nil
isRunningAI = false
2026-03-22 02:36:24 -05:00
containerTypeSelected = false
showContainerTypePicker = false
2026-03-22 01:35:53 -05:00
aiTriggered = false
aiNameConfidence = 0
lastAITriggerTime = .distantPast
aiSatisfied = false
allOCRLines = []
debugBarcode = ""
debugOCRLineCount = 0
debugPubChemName = ""
debugAIName = ""
debugArbitration = ""
debugAIQueryCount = 0
isLookingUpPubChem = false
}
2026-03-22 01:35:53 -05:00
func startScanning() {
resetScanState()
isScanning = true
2026-03-22 02:36:24 -05:00
showContainerTypePicker = true
2026-03-22 01:35:53 -05:00
}
2026-03-22 01:35:53 -05:00
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"
}
2026-03-22 01:35:53 -05:00
} else {
debugAIName = "iOS 18.1+ req"
}
2026-03-22 01:35:53 -05:00
isRunningAI = false
}
}
}
2026-03-22 01:35:53 -05:00
func finishSession() {
isScanning = false
showReview = true
}
private func resetAccumulators() {
accumulators.values.forEach { $0.reset() }
}
}
2026-03-22 01:35:53 -05:00
// MARK: - ScanView (main)
struct ScanView: View {
@Environment(\.dismiss) private var dismiss
@State private var viewModel = LabelScannerViewModel()
var body: some View {
2026-03-22 01:35:53 -05:00
NavigationStack {
ZStack {
2026-03-22 01:35:53 -05:00
// 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)
}
}
}
}
2026-03-22 01:35:53 -05:00
.task { await viewModel.loadProfile() }
.sheet(isPresented: $viewModel.showReview) {
ScanReviewView(viewModel: viewModel, onDone: { dismiss() })
}
2026-03-22 02:36:24 -05:00
.sheet(isPresented: $viewModel.showContainerTypePicker) {
ContainerTypePickerSheet(viewModel: viewModel)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
2026-03-22 01:35:53 -05:00
.animation(.easeInOut(duration: 0.3), value: viewModel.isScanning)
}
}
2026-03-22 01:35:53 -05:00
// MARK: - Live scanner UIViewControllerRepresentable
2026-03-22 01:35:53 -05:00
struct LiveScannerView: UIViewControllerRepresentable {
let viewModel: LabelScannerViewModel
func makeUIViewController(context: Context) -> DataScannerViewController {
let scanner = DataScannerViewController(
recognizedDataTypes: [.text(), .barcode()],
2026-03-22 01:35:53 -05:00
qualityLevel: .accurate,
recognizesMultipleItems: true,
2026-03-22 01:35:53 -05:00
isHighFrameRateTrackingEnabled: false,
isHighlightingEnabled: false // we draw our own banner, no overlays needed
)
scanner.delegate = context.coordinator
2026-03-22 01:35:53 -05:00
context.coordinator.viewModel = viewModel
try? scanner.startScanning()
return scanner
}
func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {}
2026-03-22 01:35:53 -05:00
func makeCoordinator() -> Coordinator { Coordinator() }
2026-03-22 01:35:53 -05:00
@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]) {
2026-03-22 01:35:53 -05:00
// 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]) {
2026-03-22 01:35:53 -05:00
// 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)
}
2026-03-22 01:35:53 -05:00
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)
}
}
2026-03-22 01:35:53 -05:00
}
}
2026-03-22 02:36:24 -05:00
// 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)
}
}
}
}
2026-03-22 01:35:53 -05:00
// MARK: - Scan banner
2026-03-22 01:35:53 -05:00
struct ScanBanner: View {
@Bindable var viewModel: LabelScannerViewModel
2026-03-22 01:35:53 -05:00
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)
}
2026-03-22 02:36:24 -05:00
}
2026-03-22 01:35:53 -05:00
.padding(.horizontal, 16)
.padding(.vertical, 6)
.transition(.opacity.combined(with: .move(edge: .top)))
}
2026-03-22 01:35:53 -05:00
// 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)
}
2026-03-22 01:35:53 -05:00
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
.background(.ultraThinMaterial.opacity(0.92))
.background(Color.black.opacity(0.55))
.animation(.easeInOut(duration: 0.2), value: viewModel.showDebug)
}
private var amountPreview: String {
let a = viewModel.previewFields["amount"] ?? ""
let u = viewModel.previewFields["unit"] ?? ""
return (a.isEmpty && u.isEmpty) ? "" : "\(a) \(u)".trimmingCharacters(in: .whitespaces)
}
}
2026-03-22 01:35:53 -05:00
private struct BannerField: View {
let label: String
let value: String
var locked: Bool { value != "" }
var body: some View {
HStack(spacing: 4) {
Text(label)
.font(.caption2)
.foregroundStyle(.white.opacity(0.6))
.frame(width: 46, alignment: .leading)
Text(value)
.font(.caption.weight(locked ? .semibold : .regular))
.foregroundStyle(locked ? .white : .white.opacity(0.4))
.lineLimit(1)
.truncationMode(.tail)
}
2026-03-22 01:35:53 -05:00
.frame(maxWidth: .infinity, alignment: .leading)
}
}
2026-03-22 01:35:53 -05:00
// MARK: - Bottom bar
2026-03-22 01:35:53 -05:00
struct ScanBottomBar: View {
let viewModel: LabelScannerViewModel
var body: some View {
2026-03-22 01:35:53 -05:00
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()
}
2026-03-22 01:35:53 -05:00
} label: {
ZStack {
Circle()
.fill(viewModel.isScanning ? Color(.brandDestructive) : Color(.brandPrimary))
.frame(width: 72, height: 72)
Image(systemName: viewModel.isScanning ? "stop.fill" : "play.fill")
.font(.title2)
.foregroundStyle(.white)
}
2026-03-22 01:35:53 -05:00
}
.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))
}
}
2026-03-22 01:35:53 -05:00
// MARK: - Review view
2026-03-22 01:35:53 -05:00
struct ScanReviewView: View {
@Bindable var viewModel: LabelScannerViewModel
let onDone: () -> Void
2026-03-22 01:35:53 -05:00
@State private var isSavingAll = false
@State private var saveError: String?
@State private var editingItem: ScannedItem?
@State private var savedCount = 0
2026-03-22 01:35:53 -05:00
private let chemicalsClient = ChemicalsClient()
2026-03-22 01:35:53 -05:00
var body: some View {
NavigationStack {
List {
Section {
Text("Review \(viewModel.scannedItems.count) scanned item\(viewModel.scannedItems.count == 1 ? "" : "s"). Tap any item to edit before saving.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
ForEach(viewModel.scannedItems) { item in
ScannedItemRow(item: item)
.contentShape(Rectangle())
.onTapGesture { editingItem = item }
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
viewModel.scannedItems.removeAll { $0.id == item.id }
} label: {
Label("Delete", systemImage: "trash")
}
}
2026-03-22 01:35:53 -05:00
}
if let error = saveError {
Section {
Text(error).foregroundStyle(.red).font(.footnote)
}
2026-03-22 01:35:53 -05:00
}
2026-03-22 01:35:53 -05:00
Section {
Button {
Task { await saveAll() }
} label: {
if isSavingAll {
HStack {
ProgressView().scaleEffect(0.8)
Text("Saving \(savedCount)/\(viewModel.scannedItems.count)")
}
.frame(maxWidth: .infinity)
} else {
Text("Save All to Inventory")
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
}
}
2026-03-22 01:35:53 -05:00
.buttonStyle(.borderedProminent)
.tint(Color(.brandPrimary))
.disabled(viewModel.scannedItems.isEmpty || isSavingAll)
}
}
.navigationTitle("Review Scans")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Back") {
viewModel.showReview = false
}
}
}
}
.sheet(item: $editingItem) { item in
ScannedItemEditView(
item: item,
piFirstName: viewModel.piFirstName,
bldgCode: viewModel.bldgCode,
lab: viewModel.lab,
storageLocation: viewModel.storageLocation,
contact: viewModel.contact
) { updated in
if let idx = viewModel.scannedItems.firstIndex(where: { $0.id == updated.id }) {
viewModel.scannedItems[idx] = updated
}
2026-03-22 01:35:53 -05:00
editingItem = nil
}
}
}
2026-03-22 01:35:53 -05:00
private func saveAll() async {
isSavingAll = true
saveError = nil
savedCount = 0
var failed = 0
2026-03-22 01:35:53 -05:00
for item in viewModel.scannedItems {
let body = ChemicalCreateBody(
piFirstName: viewModel.piFirstName.isEmpty ? "Unknown" : viewModel.piFirstName,
physicalState: item.physicalState.isEmpty ? "Solid" : item.physicalState,
chemicalName: item.chemicalName.isEmpty ? "Unknown Chemical" : item.chemicalName,
bldgCode: viewModel.bldgCode.isEmpty ? "Unknown" : viewModel.bldgCode,
lab: viewModel.lab.isEmpty ? "Unknown" : viewModel.lab,
storageLocation: viewModel.storageLocation.isEmpty ? "Unknown" : viewModel.storageLocation,
storageDevice: item.storageDevice,
numberOfContainers: "1",
amountPerContainer: item.amountPerContainer.isEmpty ? "Unknown" : item.amountPerContainer,
unitOfMeasure: item.unitOfMeasure.isEmpty ? "Unknown" : item.unitOfMeasure,
casNumber: item.casNumber.isEmpty ? "Unknown" : item.casNumber,
chemicalFormula: item.chemicalFormula.isEmpty ? nil : item.chemicalFormula,
molecularWeight: item.molecularWeight.isEmpty ? nil : item.molecularWeight,
vendor: item.vendor.isEmpty ? nil : item.vendor,
catalogNumber: item.catalogNumber.isEmpty ? nil : item.catalogNumber,
lotNumber: item.lotNumber.isEmpty ? nil : item.lotNumber,
expirationDate: item.expirationDate.isEmpty ? nil : item.expirationDate,
concentration: item.concentration.isEmpty ? nil : item.concentration,
percentageFull: item.percentageFull,
barcode: item.barcode,
contact: viewModel.contact.isEmpty ? nil : viewModel.contact
)
do {
_ = try await chemicalsClient.create(body)
savedCount += 1
} catch {
failed += 1
}
}
2026-03-22 01:35:53 -05:00
isSavingAll = false
if failed > 0 {
saveError = "\(failed) item(s) failed to save. The rest were saved successfully."
} else {
onDone()
}
}
}
2026-03-22 01:35:53 -05:00
// MARK: - Scanned item row (review list)
2026-03-22 01:35:53 -05:00
private struct ScannedItemRow: View {
let item: ScannedItem
2026-03-22 01:35:53 -05:00
var body: some View {
HStack(spacing: 12) {
if let thumb = item.thumbnail {
Image(uiImage: thumb)
.resizable()
.scaledToFill()
.frame(width: 52, height: 52)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
RoundedRectangle(cornerRadius: 8)
.fill(Color.secondary.opacity(0.15))
.frame(width: 52, height: 52)
.overlay {
Image(systemName: "flask.fill")
.foregroundStyle(.secondary)
}
}
VStack(alignment: .leading, spacing: 3) {
Text(item.chemicalName.isEmpty ? "Unnamed chemical" : item.chemicalName)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
HStack(spacing: 6) {
if !item.casNumber.isEmpty {
Text("CAS: \(item.casNumber)").font(.caption).foregroundStyle(.secondary)
}
if !item.vendor.isEmpty {
Text("· \(item.vendor)").font(.caption).foregroundStyle(.secondary)
}
}
HStack(spacing: 6) {
if !item.amountPerContainer.isEmpty {
Text("\(item.amountPerContainer) \(item.unitOfMeasure)").font(.caption2).foregroundStyle(.secondary)
}
Text("· \(Int(item.percentageFull))% full").font(.caption2).foregroundStyle(.secondary)
}
}
2026-03-22 01:35:53 -05:00
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
2026-03-22 01:35:53 -05:00
.padding(.vertical, 4)
}
}
2026-03-22 01:35:53 -05:00
// 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 {
2026-03-22 01:35:53 -05:00
NavigationStack {
Form {
Section("Identity") {
LabeledTextField("Chemical Name", text: $item.chemicalName)
LabeledTextField("CAS #", text: $item.casNumber)
Picker("Physical State", selection: $item.physicalState) {
Text("Select…").tag("")
ForEach(["Solid","Liquid","Gas"], id: \.self) { Text($0).tag($0) }
}
2026-03-22 01:35:53 -05:00
Picker("Storage Device", selection: $item.storageDevice) {
ForEach(AddChemicalViewModel.storageDevices, id: \.self) { Text($0).tag($0) }
}
}
Section("Location") {
LabeledTextField("PI First Name", text: $piFirstName)
LabeledTextField("Building Code", text: $bldgCode)
LabeledTextField("Lab", text: $lab)
LabeledTextField("Storage Location", text: $storageLocation)
}
Section("Quantity") {
LabeledTextField("Amount / Container", text: $item.amountPerContainer)
LabeledTextField("Unit of Measure", text: $item.unitOfMeasure)
HStack {
Text("Fill Level")
Spacer()
Text("\(Int(item.percentageFull))%")
.foregroundStyle(.secondary)
}
Slider(value: $item.percentageFull, in: 0...100, step: 5)
.tint(Color(.brandPrimary))
}
Section("Details") {
LabeledTextField("Formula", text: $item.chemicalFormula)
LabeledTextField("MW", text: $item.molecularWeight)
LabeledTextField("Vendor", text: $item.vendor)
LabeledTextField("Catalog #", text: $item.catalogNumber)
LabeledTextField("Lot #", text: $item.lotNumber)
LabeledTextField("Expiration", text: $item.expirationDate)
LabeledTextField("Concentration", text: $item.concentration)
}
}
.navigationTitle("Edit Item")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
var updated = item
updated.percentageFull = item.percentageFull
onSave(updated)
}
.fontWeight(.semibold)
}
}
}
}
}
2026-03-22 01:35:53 -05:00
private struct LabeledTextField: View {
let label: String
@Binding var text: String
2026-03-22 01:35:53 -05:00
init(_ label: String, text: Binding<String>) {
self.label = label
self._text = text
}
var body: some View {
HStack {
2026-03-22 01:35:53 -05:00
Text(label).foregroundStyle(Color(UIColor.label))
Spacer(minLength: 8)
TextField(label, text: $text)
.multilineTextAlignment(.trailing)
2026-03-22 01:35:53 -05:00
.foregroundStyle(Color(UIColor.secondaryLabel))
}
}
}