import SwiftUI import LabWiseKit // MARK: - View Model @Observable final class AddChemicalViewModel { // Required fields var chemicalName = "" var casNumber = "" var physicalState = "" var storageDevice = "Glass Bottle" var piFirstName = "" var bldgCode = "" var lab = "" var storageLocation = "" var numberOfContainers = "1" var amountPerContainer = "" var unitOfMeasure = "" // Optional fields var chemicalFormula = "" var molecularWeight = "" var concentration = "" var percentageFull = "" var vendor = "" var catalogNumber = "" var lotNumber = "" var expirationDate = "" // ISO date string "YYYY-MM-DD", empty = none var hasExpirationDate = false // drives the DatePicker toggle var expirationDatePicker = Date() var barcode = "" var contact = "" var comments = "" var isSaving = false var formError: String? /// Non-nil when editing an existing chemical. private let editingID: String? private let chemicalsClient = ChemicalsClient() private let profileClient = ProfileClient() static let isoDateFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "yyyy-MM-dd" f.locale = Locale(identifier: "en_US_POSIX") return f }() static let physicalStates = ["Solid", "Liquid", "Gas"] static let storageDevices = [ "Aerosol Can", "Ampule", "Bulked Item", "Fiber Box", "Gas Cylinder", "Glass Bottle", "Metal Can", "Metal Drum", "Metal Open Drum", "Pallet", "Plastic Bag", "Plastic Bottle", "Plastic Drum", "Plastic Open Drum" ] static let unitsOfMeasure = ["mL", "L", "g", "kg", "mg", "oz", "lb", "gal", "mol", "Other"] var isEditing: Bool { editingID != nil } // MARK: Init /// Add mode — fields start empty (profile-pre-filled later). init() { self.editingID = nil } /// Edit mode — all fields pre-populated from the existing chemical. init(editing chemical: Chemical) { self.editingID = chemical.id chemicalName = chemical.chemicalName casNumber = chemical.casNumber physicalState = chemical.physicalState storageDevice = chemical.storageDevice piFirstName = chemical.piFirstName bldgCode = chemical.bldgCode lab = chemical.lab storageLocation = chemical.storageLocation numberOfContainers = chemical.numberOfContainers amountPerContainer = chemical.amountPerContainer unitOfMeasure = chemical.unitOfMeasure chemicalFormula = chemical.chemicalFormula ?? "" molecularWeight = chemical.molecularWeight ?? "" concentration = chemical.concentration ?? "" percentageFull = chemical.percentageFull.map { String(Int($0)) } ?? "" vendor = chemical.vendor ?? "" catalogNumber = chemical.catalogNumber ?? "" lotNumber = chemical.lotNumber ?? "" if let exp = chemical.expirationDate, !exp.isEmpty, let parsed = Self.isoDateFormatter.date(from: exp) { expirationDate = exp hasExpirationDate = true expirationDatePicker = parsed } barcode = chemical.barcode ?? "" contact = chemical.contact ?? "" comments = chemical.comments ?? "" } // MARK: Validation var missingRequiredFields: [String] { var missing: [String] = [] if chemicalName.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("Chemical Name") } if casNumber.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("CAS #") } if physicalState.isEmpty { missing.append("Physical State") } if storageDevice.isEmpty { missing.append("Storage Device") } 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 numberOfContainers.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("# of Containers") } if amountPerContainer.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("Amount / Container") } if unitOfMeasure.isEmpty { missing.append("Unit of Measure") } return missing } // MARK: Load profile (add mode only) func loadProfile() async { guard !isEditing else { return } if let profile = try? await profileClient.get() { piFirstName = profile.piFirstName ?? "" bldgCode = profile.bldgCode ?? "" lab = profile.lab ?? "" contact = profile.contact ?? "" } } // MARK: Save (POST or PATCH) func save() async -> Bool { guard missingRequiredFields.isEmpty else { formError = "Please fill in all required fields." return false } isSaving = true formError = nil defer { isSaving = false } // Sync the date picker value into the string field before saving if hasExpirationDate { expirationDate = Self.isoDateFormatter.string(from: expirationDatePicker) } else { expirationDate = "" } let body = ChemicalCreateBody( piFirstName: piFirstName, physicalState: physicalState, chemicalName: chemicalName, bldgCode: bldgCode, lab: lab, storageLocation: storageLocation, storageDevice: storageDevice, numberOfContainers: numberOfContainers, amountPerContainer: amountPerContainer, unitOfMeasure: 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: Double(percentageFull), comments: comments.isEmpty ? nil : comments, barcode: barcode.isEmpty ? nil : barcode, contact: contact.isEmpty ? nil : contact ) do { if let id = editingID { _ = try await chemicalsClient.update(id: id, body: body) } else { _ = try await chemicalsClient.create(body) } return true } catch { formError = isEditing ? "Failed to update chemical." : "Failed to save chemical." return false } } } // MARK: - View struct AddChemicalView: View { let onDismiss: (Bool) -> Void @State private var viewModel: AddChemicalViewModel @State private var showOptional: Bool /// Add mode init(onDismiss: @escaping (Bool) -> Void) { self.onDismiss = onDismiss self._viewModel = State(initialValue: AddChemicalViewModel()) self._showOptional = State(initialValue: false) } /// Edit mode — optional fields auto-expanded, all values pre-filled init(editing chemical: Chemical, onDismiss: @escaping (Bool) -> Void) { self.onDismiss = onDismiss self._viewModel = State(initialValue: AddChemicalViewModel(editing: chemical)) self._showOptional = State(initialValue: true) } var body: some View { NavigationStack { Form { // MARK: Identity Section("Identity") { FormTextField("Chemical Name", text: $viewModel.chemicalName, required: true) FormTextField("CAS #", text: $viewModel.casNumber, required: true, placeholder: "e.g. 67-56-1") Picker("Physical State", selection: $viewModel.physicalState) { Text("Select…").tag("") ForEach(AddChemicalViewModel.physicalStates, id: \.self) { state in Text(state).tag(state) } } Picker("Storage Device", selection: $viewModel.storageDevice) { ForEach(AddChemicalViewModel.storageDevices, id: \.self) { device in Text(device).tag(device) } } } // MARK: Location Section("Location") { FormTextField("PI First Name", text: $viewModel.piFirstName, required: true) FormTextField("Building Code", text: $viewModel.bldgCode, required: true) FormTextField("Lab", text: $viewModel.lab, required: true) FormTextField("Storage Location", text: $viewModel.storageLocation, required: true, placeholder: "e.g. Cabinet A-3") } // MARK: Quantity Section("Quantity") { FormTextField("# of Containers", text: $viewModel.numberOfContainers, required: true, keyboard: .numberPad) FormTextField("Amount / Container", text: $viewModel.amountPerContainer, required: true, placeholder: "e.g. 500") Picker("Unit of Measure", selection: $viewModel.unitOfMeasure) { Text("Select…").tag("") ForEach(AddChemicalViewModel.unitsOfMeasure, id: \.self) { unit in Text(unit).tag(unit) } } } // MARK: Optional toggle Section { Button { withAnimation { showOptional.toggle() } } label: { HStack { Text(showOptional ? "Hide optional fields" : "Show optional fields") .foregroundStyle(Color(.brandPrimary)) Spacer() Image(systemName: showOptional ? "chevron.up" : "chevron.down") .foregroundStyle(Color(.brandPrimary)) .font(.caption) } } .buttonStyle(.plain) } // MARK: Optional fields if showOptional { Section("Details") { FormTextField("Chemical Formula", text: $viewModel.chemicalFormula, placeholder: "e.g. CH₃OH") FormTextField("Molecular Weight", text: $viewModel.molecularWeight, placeholder: "g/mol") FormTextField("Concentration", text: $viewModel.concentration, placeholder: "e.g. 99.8%") FormTextField("% Full", text: $viewModel.percentageFull, keyboard: .decimalPad) FormTextField("Vendor", text: $viewModel.vendor) FormTextField("Catalog #", text: $viewModel.catalogNumber) FormTextField("Lot #", text: $viewModel.lotNumber) ExpirationDateRow( hasDate: $viewModel.hasExpirationDate, date: $viewModel.expirationDatePicker ) FormTextField("Barcode", text: $viewModel.barcode) FormTextField("Contact", text: $viewModel.contact) FormTextField("Comments", text: $viewModel.comments) } } // MARK: Error + Save Section { if let error = viewModel.formError { Text(error) .foregroundStyle(.red) .font(.footnote) } Button { Task { let saved = await viewModel.save() if saved { onDismiss(true) } } } label: { if viewModel.isSaving { ProgressView() .frame(maxWidth: .infinity) } else { Text(viewModel.isEditing ? "Save Changes" : "Save Chemical") .frame(maxWidth: .infinity) .fontWeight(.semibold) } } .buttonStyle(.borderedProminent) .tint(Color(.brandPrimary)) .disabled(!viewModel.missingRequiredFields.isEmpty || viewModel.isSaving) } } .navigationTitle(viewModel.isEditing ? "Edit Chemical" : "Add Chemical") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { onDismiss(false) } } } } .task { await viewModel.loadProfile() } } } // MARK: - Expiration date picker row private struct ExpirationDateRow: View { @Binding var hasDate: Bool @Binding var date: Date var body: some View { Toggle(isOn: $hasDate.animation()) { Text("Expiration Date") } .tint(Color(.brandPrimary)) if hasDate { DatePicker( "Date", selection: $date, displayedComponents: .date ) .datePickerStyle(.compact) } } } // MARK: - Helper: labelled text field row private struct FormTextField: View { let label: String @Binding var text: String var required: Bool = false var placeholder: String = "" var keyboard: UIKeyboardType = .default init(_ label: String, text: Binding, required: Bool = false, placeholder: String = "", keyboard: UIKeyboardType = .default) { self.label = label self._text = text self.required = required self.placeholder = placeholder self.keyboard = keyboard } var body: some View { HStack { Text(label) .foregroundStyle(required ? Color(.brandPrimary) : Color(UIColor.label)) Spacer(minLength: 8) TextField(placeholder.isEmpty ? label : placeholder, text: $text) .multilineTextAlignment(.trailing) .keyboardType(keyboard) .foregroundStyle(Color(UIColor.secondaryLabel)) } } }