More UI Changes? Not sure why they didn't go previously

This commit is contained in:
2026-03-20 02:06:19 -05:00
parent efec14139c
commit 2110c13ea1
24 changed files with 1688 additions and 70 deletions

58
LabWise/AppState.swift Normal file
View File

@@ -0,0 +1,58 @@
import SwiftUI
import LabWiseKit
@Observable
final class AppState {
var isAuthenticated: Bool = false
var currentUser: AuthUser?
static let shared = AppState()
private let authClient = AuthClient()
private init() {
// Wire 401 handler
APIClient.shared.onUnauthorized = { [weak self] in
guard let self else { return }
Task { @MainActor in
self.signedOut()
}
}
// If a session cookie exists, validate it with the server on launch
let hasCookie = HTTPCookieStorage.shared.cookies?.contains {
$0.name == "__Secure-better-auth.session_token" || $0.name == "better-auth.session_token"
} ?? false
if hasCookie {
// Optimistically show the authenticated UI, then validate
isAuthenticated = true
Task { @MainActor [weak self] in
await self?.validateExistingSession()
}
}
}
@MainActor
private func validateExistingSession() async {
do {
let user = try await authClient.fetchCurrentUser()
currentUser = user
isAuthenticated = true
} catch {
// Cookie was stale or invalid
signedOut()
}
}
@MainActor
func signedIn(user: AuthUser) {
currentUser = user
isAuthenticated = true
}
@MainActor
func signedOut() {
currentUser = nil
isAuthenticated = false
}
}

View File

@@ -0,0 +1,81 @@
import SwiftUI
import LabWiseKit
struct ChemicalDetailView: View {
let chemical: Chemical
var body: some View {
Form {
Section("Identity") {
LabeledContent("Name", value: chemical.chemicalName)
LabeledContent("CAS Number", value: chemical.casNumber)
if let formula = chemical.chemicalFormula {
LabeledContent("Formula", value: formula)
}
if let mw = chemical.molecularWeight {
LabeledContent("Molecular Weight", value: mw)
}
LabeledContent("Physical State", value: chemical.physicalState.capitalized)
if let conc = chemical.concentration {
LabeledContent("Concentration", value: conc)
}
}
Section("Storage") {
LabeledContent("Location", value: chemical.storageLocation)
LabeledContent("Device", value: chemical.storageDevice)
LabeledContent("Building", value: chemical.bldgCode)
LabeledContent("Lab", value: chemical.lab)
LabeledContent("Containers", value: chemical.numberOfContainers)
LabeledContent("Amount / Container", value: "\(chemical.amountPerContainer) \(chemical.unitOfMeasure)")
if let pct = chemical.percentageFull {
LabeledContent("% Full") {
HStack(spacing: 8) {
PercentageBar(value: pct / 100)
.frame(width: 80, height: 6)
Text("\(Int(pct))%")
.font(.callout)
}
}
}
}
Section("Vendor") {
LabeledContent("PI", value: chemical.piFirstName)
if let vendor = chemical.vendor {
LabeledContent("Vendor", value: vendor)
}
if let catalog = chemical.catalogNumber {
LabeledContent("Catalog #", value: catalog)
}
if let lot = chemical.lotNumber {
LabeledContent("Lot #", value: lot)
}
if let exp = chemical.expirationDate {
LabeledContent("Expiration", value: exp)
}
if let barcode = chemical.barcode {
LabeledContent("Barcode", value: barcode)
}
}
if let comments = chemical.comments, !comments.isEmpty {
Section("Comments") {
Text(comments)
.font(.body)
}
}
Section("Record") {
if let created = chemical.createdAt {
LabeledContent("Created", value: created)
}
if let updated = chemical.updatedAt {
LabeledContent("Updated", value: updated)
}
}
}
.navigationTitle(chemical.chemicalName)
.navigationBarTitleDisplayMode(.inline)
}
}

View File

@@ -0,0 +1,147 @@
import SwiftUI
import LabWiseKit
@Observable
final class ChemicalsViewModel {
var chemicals: [Chemical] = []
var isLoading = false
var errorMessage: String?
private let client = ChemicalsClient()
func loadChemicals() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
chemicals = try await client.list()
} catch {
errorMessage = "Failed to load chemicals."
}
}
func delete(chemical: Chemical) async {
do {
try await client.delete(id: chemical.id)
chemicals.removeAll { $0.id == chemical.id }
} catch {
errorMessage = "Failed to delete chemical."
}
}
}
struct ChemicalsListView: View {
@State private var viewModel = ChemicalsViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading && viewModel.chemicals.isEmpty {
ProgressView("Loading chemicals...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewModel.chemicals.isEmpty {
ContentUnavailableView(
"No Chemicals",
systemImage: "flask",
description: Text("Your chemical inventory is empty.")
)
} else {
List {
ForEach(viewModel.chemicals) { chemical in
NavigationLink(destination: ChemicalDetailView(chemical: chemical)) {
ChemicalRowView(chemical: chemical)
}
}
.onDelete { indexSet in
for index in indexSet {
let chemical = viewModel.chemicals[index]
Task { await viewModel.delete(chemical: chemical) }
}
}
}
.refreshable {
await viewModel.loadChemicals()
}
}
}
.navigationTitle("Chemicals")
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") { viewModel.errorMessage = nil }
} message: {
Text(viewModel.errorMessage ?? "")
}
}
.task {
await viewModel.loadChemicals()
}
}
}
struct ChemicalRowView: View {
let chemical: Chemical
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(chemical.chemicalName)
.font(.headline)
Spacer()
PhysicalStateBadge(state: chemical.physicalState)
}
Text("CAS: \(chemical.casNumber)")
.font(.caption)
.foregroundStyle(.secondary)
if let pct = chemical.percentageFull {
PercentageBar(value: pct / 100)
.frame(height: 4)
.padding(.top, 2)
}
}
.padding(.vertical, 2)
}
}
struct PhysicalStateBadge: View {
let state: String
var color: Color {
switch state.lowercased() {
case "liquid": return Color(.brandPrimary)
case "solid": return Color(red: 0.42, green: 0.30, blue: 0.18)
case "gas": return Color(red: 0.22, green: 0.56, blue: 0.52)
default: return Color(.brandMutedForeground)
}
}
var body: some View {
Text(state.capitalized)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(color.opacity(0.15))
.foregroundStyle(color)
.clipShape(Capsule())
}
}
struct PercentageBar: View {
let value: Double // 0.0 - 1.0
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.secondary.opacity(0.2))
RoundedRectangle(cornerRadius: 2)
.fill(barColor)
.frame(width: geo.size.width * max(0, min(1, value)))
}
}
}
var barColor: Color {
if value > 0.6 { return .green }
if value > 0.25 { return .yellow }
return .red
}
}

View File

@@ -13,7 +13,7 @@ struct DashboardView: View {
ProfileView()
}
}
.tint(Color("Brand/BrandPrimary"))
.tint(Color(.brandPrimary))
}
}

19
LabWise/Info.plist Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.adipu.labwise</string>
<key>CFBundleURLSchemes</key>
<array>
<string>labwise</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@@ -1,17 +1,19 @@
//
// LabWiseApp.swift
// LabWise
//
// Created by Aditya Pulipaka on 3/19/26.
//
import SwiftUI
import LabWiseKit
@main
struct LabWiseApp: App {
@State private var appState = AppState.shared
var body: some Scene {
WindowGroup {
ContentView()
if appState.isAuthenticated {
DashboardView()
.environment(appState)
} else {
LoginView()
.environment(appState)
}
}
}
}

View File

@@ -30,7 +30,7 @@ struct LoginView: View {
.frame(height: 52)
Text("Chemical Inventory Management")
.font(.subheadline)
.foregroundStyle(Color("Brand/BrandMutedForeground"))
.foregroundStyle(Color(.brandMutedForeground))
}
// Card
@@ -46,7 +46,7 @@ struct LoginView: View {
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color("Brand/BrandPrimary").opacity(0.2), lineWidth: 1)
.strokeBorder(Color(.brandPrimary).opacity(0.2), lineWidth: 1)
)
SecureField("Password", text: $password)
@@ -57,7 +57,7 @@ struct LoginView: View {
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color("Brand/BrandPrimary").opacity(0.2), lineWidth: 1)
.strokeBorder(Color(.brandPrimary).opacity(0.2), lineWidth: 1)
)
}
@@ -83,7 +83,7 @@ struct LoginView: View {
.frame(maxWidth: .infinity)
.frame(height: 44)
}
.background(Color("Brand/BrandPrimary"))
.background(Color(.brandPrimary))
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
.disabled(email.isEmpty || password.isEmpty || isLoading || isGoogleLoading)
@@ -91,13 +91,13 @@ struct LoginView: View {
HStack {
Rectangle()
.fill(Color("Brand/BrandPrimary").opacity(0.15))
.fill(Color(.brandPrimary).opacity(0.15))
.frame(height: 1)
Text("or")
.font(.footnote)
.foregroundStyle(Color("Brand/BrandMutedForeground"))
.foregroundStyle(Color(.brandMutedForeground))
Rectangle()
.fill(Color("Brand/BrandPrimary").opacity(0.15))
.fill(Color(.brandPrimary).opacity(0.15))
.frame(height: 1)
}
@@ -109,7 +109,7 @@ struct LoginView: View {
.padding(24)
.background(Color(UIColor.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: Color("Brand/BrandPrimary").opacity(0.06), radius: 12, x: 0, y: 4)
.shadow(color: Color(.brandPrimary).opacity(0.06), radius: 12, x: 0, y: 4)
.padding(.horizontal, 24)
Spacer()
@@ -174,8 +174,8 @@ struct GoogleSignInButton: View {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Image(systemName: "globe")
.foregroundStyle(Color("Brand/BrandPrimary"))
GoogleGLogo()
.frame(width: 18, height: 18)
Text("Continue with Google")
.fontWeight(.medium)
.foregroundStyle(Color(UIColor.label))
@@ -187,13 +187,23 @@ struct GoogleSignInButton: View {
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color("Brand/BrandPrimary").opacity(0.25), lineWidth: 1)
.strokeBorder(Color(.brandPrimary).opacity(0.25), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
/// The four-colour Google "G" logo, matching the web app implementation.
struct GoogleGLogo: View {
var body: some View {
Image("GoogleLogo")
.resizable()
.renderingMode(.original)
.scaledToFit()
}
}
#Preview {
LoginView()
.environment(AppState.shared)

144
LabWise/ProfileView.swift Normal file
View File

@@ -0,0 +1,144 @@
import SwiftUI
import LabWiseKit
@Observable
final class ProfileViewModel {
var profile: UserProfile?
var isLoading = false
var isSaving = false
var isEditing = false
var errorMessage: String?
// Editable fields
var piFirstName = ""
var bldgCode = ""
var lab = ""
var contact = ""
private let profileClient = ProfileClient()
private let authClient = AuthClient()
func load() async {
isLoading = true
defer { isLoading = false }
do {
let p = try await profileClient.get()
profile = p
populateFields(from: p)
} catch {
// Profile may not exist yet that's OK
}
}
func save() async {
isSaving = true
defer { isSaving = false }
do {
let body = UserProfileUpsertBody(
piFirstName: piFirstName,
bldgCode: bldgCode,
lab: lab,
contact: contact.isEmpty ? nil : contact
)
let updated = try await profileClient.upsert(body)
profile = updated
populateFields(from: updated)
isEditing = false
} catch {
errorMessage = "Failed to save profile."
}
}
func signOut() async {
try? await authClient.signOut()
await MainActor.run {
AppState.shared.signedOut()
}
}
private func populateFields(from p: UserProfile) {
piFirstName = p.piFirstName
bldgCode = p.bldgCode
lab = p.lab
contact = p.contact ?? ""
}
}
struct ProfileView: View {
@Environment(AppState.self) private var appState
@State private var viewModel = ProfileViewModel()
var body: some View {
NavigationStack {
Form {
if let user = appState.currentUser {
Section("Account") {
LabeledContent("Email", value: user.email)
}
}
Section("Lab Info") {
if viewModel.isEditing {
TextField("PI First Name", text: $viewModel.piFirstName)
TextField("Building Code", text: $viewModel.bldgCode)
TextField("Lab", text: $viewModel.lab)
TextField("Contact", text: $viewModel.contact)
} else {
LabeledContent("PI First Name", value: viewModel.piFirstName.isEmpty ? "" : viewModel.piFirstName)
LabeledContent("Building Code", value: viewModel.bldgCode.isEmpty ? "" : viewModel.bldgCode)
LabeledContent("Lab", value: viewModel.lab.isEmpty ? "" : viewModel.lab)
LabeledContent("Contact", value: viewModel.contact.isEmpty ? "" : viewModel.contact)
}
}
Section {
Button(role: .destructive) {
Task { await viewModel.signOut() }
} label: {
HStack {
Spacer()
Text("Sign Out")
Spacer()
}
}
}
}
.navigationTitle("Profile")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if viewModel.isEditing {
Button("Save") {
Task { await viewModel.save() }
}
.disabled(viewModel.isSaving)
} else {
Button("Edit") {
viewModel.isEditing = true
}
}
}
if viewModel.isEditing {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
viewModel.isEditing = false
if let p = viewModel.profile {
viewModel.piFirstName = p.piFirstName
viewModel.bldgCode = p.bldgCode
viewModel.lab = p.lab
viewModel.contact = p.contact ?? ""
}
}
}
}
}
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") { viewModel.errorMessage = nil }
} message: {
Text(viewModel.errorMessage ?? "")
}
}
.task {
await viewModel.load()
}
}
}

609
LabWise/ScanView.swift Normal file
View File

@@ -0,0 +1,609 @@
import SwiftUI
import VisionKit
import LabWiseKit
// MARK: - Scan stages
enum ScanStage {
case camera
case analyzing
case review
}
// MARK: - Extracted label data (Foundation Models @Generable output)
import FoundationModels
@Generable
struct ExtractedLabelData {
@Guide(description: "CAS registry number, e.g. 7732-18-5")
var casNumber: String
@Guide(description: "Full chemical name")
var chemicalName: String
@Guide(description: "Chemical formula, e.g. H2O")
var chemicalFormula: String
@Guide(description: "Molecular weight with unit, e.g. 18.015 g/mol")
var molecularWeight: String
@Guide(description: "Vendor or manufacturer name")
var vendor: String
@Guide(description: "Catalog number from vendor")
var catalogNumber: String
@Guide(description: "Lot number")
var lotNumber: String
@Guide(description: "Concentration, e.g. 1M, 98%")
var concentration: String
@Guide(description: "Expiration date in ISO format YYYY-MM-DD if found")
var expirationDate: String
@Guide(description: "Physical state: liquid, solid, or gas")
var physicalState: String
@Guide(description: "Storage device inferred from label: flammable cabinet, fridge, freezer, ambient, etc.")
var storageDevice: String
@Guide(description: "Container type: bottle, cylinder, drum, vial, etc.")
var containerType: String
}
// MARK: - ScanViewModel
@Observable
final class ScanViewModel {
var stage: ScanStage = .camera
var capturedImage: UIImage?
var capturedTexts: [String] = []
var scannedBarcode: String?
// Extracted fields
var casNumber = ""
var chemicalName = ""
var chemicalFormula = ""
var molecularWeight = ""
var vendor = ""
var catalogNumber = ""
var lotNumber = ""
var concentration = ""
var expirationDate = ""
var physicalState = ""
var storageDevice = ""
var numberOfContainers = "1"
var amountPerContainer = ""
var unitOfMeasure = ""
var percentageFull: Double = 50
// Pre-populated from profile
var piFirstName = ""
var bldgCode = ""
var lab = ""
var storageLocation = ""
var contact = ""
var analysisError: String?
var isSaving = false
var saveError: String?
var didSave = false
private let chemicalsClient = ChemicalsClient()
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 ?? ""
}
}
func analyzeTexts() async {
stage = .analyzing
analysisError = nil
guard SystemLanguageModel.default.isAvailable else {
// Fall through to review with empty fields
stage = .review
return
}
do {
let combinedText = capturedTexts.joined(separator: "\n")
let session = LanguageModelSession(instructions: """
You are a chemical label OCR assistant. Extract structured fields from the raw OCR text of a chemical reagent label.
Return only the fields you can identify. Leave fields empty string if not found.
For storageDevice infer from hazard symbols: FLAMMABLE → flammable cabinet, KEEP REFRIGERATED → fridge, KEEP FROZEN → freezer.
""")
let response = try await session.respond(
to: "Extract fields from this chemical label text:\n\n\(combinedText)",
generating: ExtractedLabelData.self
)
let data = response.content
await MainActor.run {
casNumber = data.casNumber
chemicalName = data.chemicalName
chemicalFormula = data.chemicalFormula
molecularWeight = data.molecularWeight
vendor = data.vendor
catalogNumber = data.catalogNumber
lotNumber = data.lotNumber
concentration = data.concentration
expirationDate = data.expirationDate
physicalState = data.physicalState
storageDevice = data.storageDevice
}
} catch {
await MainActor.run {
analysisError = "Label analysis failed: \(error.localizedDescription)"
}
}
await MainActor.run { stage = .review }
}
var missingRequiredFields: [String] {
var missing: [String] = []
if piFirstName.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("PI First Name") }
if bldgCode.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("Building Code") }
if lab.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("Lab") }
if storageLocation.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("Storage Location") }
if chemicalName.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("Chemical Name") }
if casNumber.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("CAS Number") }
return missing
}
func save() async {
isSaving = true
saveError = nil
defer { isSaving = false }
do {
let body = ChemicalCreateBody(
piFirstName: piFirstName,
physicalState: physicalState.isEmpty ? "unknown" : physicalState,
chemicalName: chemicalName,
bldgCode: bldgCode,
lab: lab,
storageLocation: storageLocation,
storageDevice: storageDevice.isEmpty ? "unknown" : storageDevice,
numberOfContainers: numberOfContainers.isEmpty ? "1" : numberOfContainers,
amountPerContainer: amountPerContainer.isEmpty ? "unknown" : amountPerContainer,
unitOfMeasure: unitOfMeasure.isEmpty ? "unknown" : unitOfMeasure,
casNumber: casNumber,
chemicalFormula: chemicalFormula.isEmpty ? nil : chemicalFormula,
molecularWeight: molecularWeight.isEmpty ? nil : molecularWeight,
vendor: vendor.isEmpty ? nil : vendor,
catalogNumber: catalogNumber.isEmpty ? nil : catalogNumber,
lotNumber: lotNumber.isEmpty ? nil : lotNumber,
expirationDate: expirationDate.isEmpty ? nil : expirationDate,
concentration: concentration.isEmpty ? nil : concentration,
percentageFull: percentageFull,
barcode: scannedBarcode,
contact: contact.isEmpty ? nil : contact
)
_ = try await chemicalsClient.create(body)
await MainActor.run { didSave = true }
} catch {
await MainActor.run { saveError = "Failed to save: \(error.localizedDescription)" }
}
}
func reset() {
stage = .camera
capturedImage = nil
capturedTexts = []
scannedBarcode = nil
casNumber = ""; chemicalName = ""; chemicalFormula = ""
molecularWeight = ""; vendor = ""; catalogNumber = ""
lotNumber = ""; concentration = ""; expirationDate = ""
physicalState = ""; storageDevice = ""; storageLocation = ""
numberOfContainers = "1"; amountPerContainer = ""; unitOfMeasure = ""
percentageFull = 50; analysisError = nil; didSave = false; saveError = nil
}
}
// MARK: - Main ScanView
struct ScanView: View {
@State private var viewModel = ScanViewModel()
@State private var showChemicalsList = false
var body: some View {
NavigationStack {
Group {
switch viewModel.stage {
case .camera:
DataScannerWrapperView(viewModel: viewModel)
case .analyzing:
AnalyzingView()
case .review:
ReviewFormView(viewModel: viewModel, onSaved: {
showChemicalsList = true
})
}
}
.navigationTitle(stageName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if viewModel.stage != .camera {
ToolbarItem(placement: .topBarLeading) {
Button("Rescan") { viewModel.reset() }
}
}
}
}
.task { await viewModel.loadProfile() }
.onChange(of: viewModel.didSave) { _, saved in
if saved { showChemicalsList = true }
}
}
private var stageName: String {
switch viewModel.stage {
case .camera: return "Scan Label"
case .analyzing: return "Analysing..."
case .review: return "Review"
}
}
}
// MARK: - Analysing placeholder
struct AnalyzingView: View {
var body: some View {
VStack(spacing: 20) {
ProgressView()
.scaleEffect(1.5)
Text("Analysing label...")
.font(.headline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - DataScanner wrapper
struct DataScannerWrapperView: View {
let viewModel: ScanViewModel
@State private var recognizedTexts: [RecognizedItem] = []
@State private var showUnsupported = false
var body: some View {
ZStack(alignment: .bottom) {
if DataScannerViewController.isSupported && DataScannerViewController.isAvailable {
DataScannerRepresentable(
recognizedItems: $recognizedTexts,
onCapture: { image, texts, barcode in
viewModel.capturedImage = image
viewModel.capturedTexts = texts
viewModel.scannedBarcode = barcode
Task { await viewModel.analyzeTexts() }
}
)
.ignoresSafeArea()
// Capture button
VStack {
Spacer()
CaptureButton {
// Capture handled inside representable via button tap callback
}
.padding(.bottom, 40)
}
} else {
ContentUnavailableView(
"Scanner Unavailable",
systemImage: "camera.slash",
description: Text("This device does not support data scanning.")
)
}
}
}
}
struct CaptureButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
ZStack {
Circle()
.fill(.white)
.frame(width: 72, height: 72)
Circle()
.strokeBorder(.white, lineWidth: 4)
.frame(width: 84, height: 84)
}
}
}
}
// MARK: - UIViewControllerRepresentable for DataScanner
struct DataScannerRepresentable: UIViewControllerRepresentable {
@Binding var recognizedItems: [RecognizedItem]
let onCapture: (UIImage, [String], String?) -> Void
func makeUIViewController(context: Context) -> DataScannerViewController {
let scanner = DataScannerViewController(
recognizedDataTypes: [.text(), .barcode()],
qualityLevel: .balanced,
recognizesMultipleItems: true,
isHighFrameRateTrackingEnabled: true,
isHighlightingEnabled: true
)
scanner.delegate = context.coordinator
context.coordinator.scanner = scanner
context.coordinator.onCapture = onCapture
try? scanner.startScanning()
return scanner
}
func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(recognizedItems: $recognizedItems)
}
class Coordinator: NSObject, DataScannerViewControllerDelegate {
@Binding var recognizedItems: [RecognizedItem]
weak var scanner: DataScannerViewController?
var onCapture: ((UIImage, [String], String?) -> Void)?
init(recognizedItems: Binding<[RecognizedItem]>) {
_recognizedItems = recognizedItems
}
func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
recognizedItems = allItems
}
func dataScanner(_ dataScanner: DataScannerViewController, didUpdate updatedItems: [RecognizedItem], allItems: [RecognizedItem]) {
recognizedItems = allItems
}
func dataScanner(_ dataScanner: DataScannerViewController, didRemove removedItems: [RecognizedItem], allItems: [RecognizedItem]) {
recognizedItems = allItems
}
func captureCurrentFrame() {
guard let scanner else { return }
// Collect all recognized text and barcodes
var texts: [String] = []
var barcode: String?
for item in recognizedItems {
switch item {
case .text(let t):
texts.append(t.transcript)
case .barcode(let b):
if let payload = b.payloadStringValue {
barcode = payload
texts.append(payload)
}
default:
break
}
}
// Capture a snapshot of the current frame
let renderer = UIGraphicsImageRenderer(bounds: scanner.view.bounds)
let image = renderer.image { ctx in
scanner.view.layer.render(in: ctx.cgContext)
}
onCapture?(image, texts, barcode)
}
}
}
// MARK: - ReviewFormView
struct ReviewFormView: View {
@Bindable var viewModel: ScanViewModel
let onSaved: () -> Void
var body: some View {
ScrollView {
VStack(spacing: 0) {
// Captured frame + percentage slider
if let image = viewModel.capturedImage {
CapturedImageWithSlider(image: image, percentageFull: $viewModel.percentageFull)
.frame(height: 260)
.clipped()
}
if let error = viewModel.analysisError {
Text(error)
.foregroundStyle(.orange)
.font(.footnote)
.padding(.horizontal)
.padding(.top, 8)
}
Form {
// Required fields section
Section {
ReviewField("Chemical Name", text: $viewModel.chemicalName, required: true, missing: viewModel.missingRequiredFields.contains("Chemical Name"))
ReviewField("CAS Number", text: $viewModel.casNumber, required: true, missing: viewModel.missingRequiredFields.contains("CAS Number"))
ReviewField("Physical State", text: $viewModel.physicalState)
ReviewField("Storage Device", text: $viewModel.storageDevice)
} header: {
Text("Identity")
}
Section {
ReviewField("PI First Name", text: $viewModel.piFirstName, required: true, missing: viewModel.missingRequiredFields.contains("PI First Name"))
ReviewField("Building Code", text: $viewModel.bldgCode, required: true, missing: viewModel.missingRequiredFields.contains("Building Code"))
ReviewField("Lab", text: $viewModel.lab, required: true, missing: viewModel.missingRequiredFields.contains("Lab"))
ReviewField("Storage Location", text: $viewModel.storageLocation, required: true, missing: viewModel.missingRequiredFields.contains("Storage Location"))
} header: {
Text("Location")
}
Section {
ReviewField("# Containers", text: $viewModel.numberOfContainers)
ReviewField("Amount / Container", text: $viewModel.amountPerContainer)
ReviewField("Unit of Measure", text: $viewModel.unitOfMeasure)
} header: {
Text("Quantity")
}
Section {
ReviewField("Formula", text: $viewModel.chemicalFormula)
ReviewField("Molecular Weight", text: $viewModel.molecularWeight)
ReviewField("Concentration", text: $viewModel.concentration)
ReviewField("Vendor", text: $viewModel.vendor)
ReviewField("Catalog #", text: $viewModel.catalogNumber)
ReviewField("Lot #", text: $viewModel.lotNumber)
ReviewField("Expiration Date", text: $viewModel.expirationDate)
} header: {
Text("Details")
}
Section {
if let saveError = viewModel.saveError {
Text(saveError)
.foregroundStyle(.red)
.font(.footnote)
}
Button {
Task { await viewModel.save() }
} label: {
if viewModel.isSaving {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text("Save Chemical")
.frame(maxWidth: .infinity)
.fontWeight(.semibold)
}
}
.buttonStyle(.borderedProminent)
.disabled(!viewModel.missingRequiredFields.isEmpty || viewModel.isSaving)
}
if !viewModel.missingRequiredFields.isEmpty {
Section {
Text("Required: \(viewModel.missingRequiredFields.joined(separator: ", "))")
.foregroundStyle(.red)
.font(.footnote)
}
}
}
.frame(minHeight: 800)
.scrollDisabled(true)
}
}
}
}
// MARK: - Captured image with vertical % slider
struct CapturedImageWithSlider: View {
let image: UIImage
@Binding var percentageFull: Double
var body: some View {
ZStack(alignment: .trailing) {
Image(uiImage: image)
.resizable()
.scaledToFill()
// Dim overlay
Color.black.opacity(0.2)
// Vertical slider on right edge
HStack {
Spacer()
VStack(spacing: 8) {
Text("\(Int(percentageFull))%")
.font(.caption.bold())
.foregroundStyle(.white)
.shadow(radius: 2)
VerticalSlider(value: $percentageFull, range: 0...100)
.frame(width: 32, height: 180)
}
.padding(.trailing, 12)
}
}
}
}
// MARK: - Vertical slider
struct VerticalSlider: View {
@Binding var value: Double
let range: ClosedRange<Double>
@GestureState private var isDragging = false
var body: some View {
GeometryReader { geo in
ZStack(alignment: .bottom) {
// Track
Capsule()
.fill(Color.white.opacity(0.3))
// Fill
Capsule()
.fill(Color.white.opacity(0.8))
.frame(height: geo.size.height * fillFraction)
// Thumb
Circle()
.fill(.white)
.frame(width: 28, height: 28)
.shadow(radius: 3)
.offset(y: -geo.size.height * fillFraction + 14)
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { drag in
let fraction = 1 - (drag.location.y / geo.size.height)
let clamped = max(range.lowerBound, min(range.upperBound, fraction * (range.upperBound - range.lowerBound) + range.lowerBound))
value = clamped
}
)
}
}
private var fillFraction: Double {
(value - range.lowerBound) / (range.upperBound - range.lowerBound)
}
}
// MARK: - ReviewField helper
struct ReviewField: View {
let label: String
@Binding var text: String
var required: Bool = false
var missing: Bool = false
init(_ label: String, text: Binding<String>, required: Bool = false, missing: Bool = false) {
self.label = label
self._text = text
self.required = required
self.missing = missing
}
var body: some View {
HStack {
Text(label + (required ? " *" : ""))
.foregroundStyle(missing ? .red : .primary)
.frame(minWidth: 120, alignment: .leading)
TextField(label, text: $text)
.multilineTextAlignment(.trailing)
}
.listRowBackground(missing ? Color.red.opacity(0.05) : Color(uiColor: .secondarySystemGroupedBackground))
}
}