Manual Entry kinda works
This commit is contained in:
378
LabWise/AddChemicalView.swift
Normal file
378
LabWise/AddChemicalView.swift
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
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<String>, 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,8 @@ final class AppState {
|
|||||||
|
|
||||||
// If a session cookie exists, validate it with the server on launch
|
// If a session cookie exists, validate it with the server on launch
|
||||||
let hasCookie = HTTPCookieStorage.shared.cookies?.contains {
|
let hasCookie = HTTPCookieStorage.shared.cookies?.contains {
|
||||||
$0.name == "__Secure-better-auth.session_token" || $0.name == "better-auth.session_token"
|
($0.name == "__Secure-better-auth.session_token" || $0.name == "better-auth.session_token")
|
||||||
|
&& !$0.value.isEmpty
|
||||||
} ?? false
|
} ?? false
|
||||||
if hasCookie {
|
if hasCookie {
|
||||||
// Optimistically show the authenticated UI, then validate
|
// Optimistically show the authenticated UI, then validate
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import SwiftUI
|
|||||||
import LabWiseKit
|
import LabWiseKit
|
||||||
|
|
||||||
struct ChemicalDetailView: View {
|
struct ChemicalDetailView: View {
|
||||||
let chemical: Chemical
|
@State private var chemical: Chemical
|
||||||
|
@State private var showEdit = false
|
||||||
|
|
||||||
|
init(chemical: Chemical) {
|
||||||
|
self._chemical = State(initialValue: chemical)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
@@ -57,6 +62,9 @@ struct ChemicalDetailView: View {
|
|||||||
if let barcode = chemical.barcode {
|
if let barcode = chemical.barcode {
|
||||||
LabeledContent("Barcode", value: barcode)
|
LabeledContent("Barcode", value: barcode)
|
||||||
}
|
}
|
||||||
|
if let contact = chemical.contact {
|
||||||
|
LabeledContent("Contact", value: contact)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let comments = chemical.comments, !comments.isEmpty {
|
if let comments = chemical.comments, !comments.isEmpty {
|
||||||
@@ -77,5 +85,30 @@ struct ChemicalDetailView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(chemical.chemicalName)
|
.navigationTitle(chemical.chemicalName)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showEdit = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "pencil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showEdit) {
|
||||||
|
AddChemicalView(editing: chemical) { saved in
|
||||||
|
showEdit = false
|
||||||
|
if saved {
|
||||||
|
Task { await reloadChemical() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload from server so the detail view reflects the saved changes.
|
||||||
|
private func reloadChemical() async {
|
||||||
|
let updated = try? await ChemicalsClient().list()
|
||||||
|
if let match = updated?.first(where: { $0.id == chemical.id }) {
|
||||||
|
chemical = match
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import LabWiseKit
|
import LabWiseKit
|
||||||
|
|
||||||
@Observable
|
// MARK: - Chemical row
|
||||||
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 {
|
struct ChemicalRowView: View {
|
||||||
let chemical: Chemical
|
let chemical: Chemical
|
||||||
@@ -101,6 +27,8 @@ struct ChemicalRowView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Physical state badge
|
||||||
|
|
||||||
struct PhysicalStateBadge: View {
|
struct PhysicalStateBadge: View {
|
||||||
let state: String
|
let state: String
|
||||||
|
|
||||||
@@ -124,8 +52,10 @@ struct PhysicalStateBadge: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Percentage bar
|
||||||
|
|
||||||
struct PercentageBar: View {
|
struct PercentageBar: View {
|
||||||
let value: Double // 0.0 - 1.0
|
let value: Double // 0.0 – 1.0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import LabWiseKit
|
||||||
|
|
||||||
|
// MARK: - Tab shell
|
||||||
|
|
||||||
struct DashboardView: View {
|
struct DashboardView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
TabView {
|
||||||
Tab("Chemicals", systemImage: "flask.fill") {
|
Tab("Dashboard", systemImage: "chart.bar.fill") {
|
||||||
ChemicalsListView()
|
DashboardHomeView()
|
||||||
}
|
}
|
||||||
Tab("Scan", systemImage: "camera.fill") {
|
Tab("Inventory", systemImage: "flask.fill") {
|
||||||
ScanView()
|
InventoryView()
|
||||||
}
|
}
|
||||||
Tab("Profile", systemImage: "person.fill") {
|
Tab("Profile", systemImage: "person.fill") {
|
||||||
ProfileView()
|
ProfileView()
|
||||||
@@ -17,6 +20,410 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Dashboard view model
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class DashboardViewModel {
|
||||||
|
var chemicals: [Chemical] = []
|
||||||
|
var isLoading = false
|
||||||
|
var errorMessage: String?
|
||||||
|
|
||||||
|
private let client = ChemicalsClient()
|
||||||
|
|
||||||
|
// MARK: Derived stats
|
||||||
|
|
||||||
|
var lowStock: [Chemical] {
|
||||||
|
chemicals
|
||||||
|
.filter { ($0.percentageFull ?? 100) < 20 }
|
||||||
|
.sorted { ($0.percentageFull ?? 0) < ($1.percentageFull ?? 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiringSoon: [Chemical] {
|
||||||
|
chemicals
|
||||||
|
.filter { c in
|
||||||
|
guard let exp = c.expirationDate else { return false }
|
||||||
|
return daysUntil(exp) <= 30
|
||||||
|
}
|
||||||
|
.sorted { a, b in
|
||||||
|
daysUntil(a.expirationDate ?? "") < daysUntil(b.expirationDate ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var recentActivity: [Chemical] {
|
||||||
|
chemicals
|
||||||
|
.sorted { ($0.createdAt ?? "") > ($1.createdAt ?? "") }
|
||||||
|
.prefix(4)
|
||||||
|
.map { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Load
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
defer { isLoading = false }
|
||||||
|
do {
|
||||||
|
chemicals = try await client.list()
|
||||||
|
} catch {
|
||||||
|
print("[DashboardViewModel] load error: \(error)")
|
||||||
|
errorMessage = "Failed to load: \(error)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Helpers
|
||||||
|
|
||||||
|
func daysUntil(_ iso: String) -> Int {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withFullDate]
|
||||||
|
guard let date = formatter.date(from: iso) else { return Int.max }
|
||||||
|
return Int(ceil(date.timeIntervalSinceNow / 86400))
|
||||||
|
}
|
||||||
|
|
||||||
|
func relativeTime(_ iso: String?) -> String {
|
||||||
|
guard let iso else { return "" }
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
guard let date = formatter.date(from: iso) else { return "" }
|
||||||
|
let seconds = Int(-date.timeIntervalSinceNow)
|
||||||
|
if seconds < 60 { return "just now" }
|
||||||
|
if seconds < 3600 { return "\(seconds / 60)m ago" }
|
||||||
|
if seconds < 86400 { return "\(seconds / 3600)h ago" }
|
||||||
|
return "\(seconds / 86400)d ago"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Main dashboard view
|
||||||
|
|
||||||
|
struct DashboardHomeView: View {
|
||||||
|
@State private var viewModel = DashboardViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if viewModel.isLoading && viewModel.chemicals.isEmpty {
|
||||||
|
ProgressView("Loading...")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
StatCardsRow(viewModel: viewModel)
|
||||||
|
SafetyAlertsSection(viewModel: viewModel)
|
||||||
|
LowStockSection(viewModel: viewModel)
|
||||||
|
ExpiringSection(viewModel: viewModel)
|
||||||
|
RecentActivitySection(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.refreshable { await viewModel.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Dashboard")
|
||||||
|
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
|
||||||
|
Button("OK") { viewModel.errorMessage = nil }
|
||||||
|
} message: {
|
||||||
|
Text(viewModel.errorMessage ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stat cards row
|
||||||
|
|
||||||
|
private struct StatCardsRow: View {
|
||||||
|
let viewModel: DashboardViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
||||||
|
StatCard(
|
||||||
|
title: "Chemicals",
|
||||||
|
value: "\(viewModel.chemicals.count)",
|
||||||
|
icon: "flask.fill",
|
||||||
|
color: Color(.brandPrimary)
|
||||||
|
)
|
||||||
|
StatCard(
|
||||||
|
title: "Low Stock",
|
||||||
|
value: "\(viewModel.lowStock.count)",
|
||||||
|
icon: "exclamationmark.triangle.fill",
|
||||||
|
color: .orange
|
||||||
|
)
|
||||||
|
StatCard(
|
||||||
|
title: "Expiring ≤30d",
|
||||||
|
value: "\(viewModel.expiringSoon.count)",
|
||||||
|
icon: "clock.fill",
|
||||||
|
color: .red
|
||||||
|
)
|
||||||
|
StatCard(
|
||||||
|
title: "Tracked",
|
||||||
|
value: viewModel.chemicals.isEmpty ? "—" : "Active",
|
||||||
|
icon: "checkmark.seal.fill",
|
||||||
|
color: .green
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StatCard: View {
|
||||||
|
let title: String
|
||||||
|
let value: String
|
||||||
|
let icon: String
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Text(value)
|
||||||
|
.font(.title.bold())
|
||||||
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(UIColor.secondarySystemGroupedBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Safety alerts
|
||||||
|
|
||||||
|
private struct SafetyAlertsSection: View {
|
||||||
|
let viewModel: DashboardViewModel
|
||||||
|
|
||||||
|
private var expired: [Chemical] {
|
||||||
|
viewModel.expiringSoon.filter { viewModel.daysUntil($0.expirationDate ?? "") <= 0 }
|
||||||
|
}
|
||||||
|
private var lowStockAlerts: [Chemical] { Array(viewModel.lowStock.prefix(2)) }
|
||||||
|
private var expiringSoonCount: Int { viewModel.expiringSoon.filter { viewModel.daysUntil($0.expirationDate ?? "") > 0 }.count }
|
||||||
|
|
||||||
|
var hasAlerts: Bool { !expired.isEmpty || !lowStockAlerts.isEmpty || expiringSoonCount > 0 }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
DashboardCard(title: "Safety Alerts", icon: "shield.fill") {
|
||||||
|
if !hasAlerts {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(Color(.brandPrimary))
|
||||||
|
Text("All clear — no active safety alerts")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(expired) { chem in
|
||||||
|
AlertRow(
|
||||||
|
message: "\(chem.chemicalName) expired \(-viewModel.daysUntil(chem.expirationDate ?? ""))d ago — verify disposal",
|
||||||
|
severity: .critical
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ForEach(lowStockAlerts) { chem in
|
||||||
|
AlertRow(
|
||||||
|
message: "\(chem.chemicalName) is \(Int(chem.percentageFull ?? 0))% full — consider reordering",
|
||||||
|
severity: .warning
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if expiringSoonCount > 0 {
|
||||||
|
AlertRow(
|
||||||
|
message: "\(expiringSoonCount) chemical(s) expiring in the next 30 days",
|
||||||
|
severity: .warning
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum AlertSeverity { case critical, warning }
|
||||||
|
|
||||||
|
private struct AlertRow: View {
|
||||||
|
let message: String
|
||||||
|
let severity: AlertSeverity
|
||||||
|
|
||||||
|
var color: Color { severity == .critical ? .red : .orange }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
.padding(.top, 5)
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color(UIColor.label))
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(color.opacity(0.08))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Low stock
|
||||||
|
|
||||||
|
private struct LowStockSection: View {
|
||||||
|
let viewModel: DashboardViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
DashboardCard(title: "Low Stock", icon: "exclamationmark.triangle.fill", iconColor: .orange) {
|
||||||
|
if viewModel.lowStock.isEmpty {
|
||||||
|
EmptyStateRow(message: "No low stock items")
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(viewModel.lowStock.prefix(3)) { chem in
|
||||||
|
ChemAlertRow(
|
||||||
|
name: chem.chemicalName,
|
||||||
|
subtitle: "\(chem.lab) · \(chem.storageLocation)",
|
||||||
|
badge: "\(Int(chem.percentageFull ?? 0))% full",
|
||||||
|
badgeColor: .orange
|
||||||
|
)
|
||||||
|
if chem.id != viewModel.lowStock.prefix(3).last?.id {
|
||||||
|
Divider().padding(.leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Expiring soon
|
||||||
|
|
||||||
|
private struct ExpiringSection: View {
|
||||||
|
let viewModel: DashboardViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
DashboardCard(title: "Expiring Soon", icon: "clock.fill", iconColor: .red) {
|
||||||
|
if viewModel.expiringSoon.isEmpty {
|
||||||
|
EmptyStateRow(message: "No chemicals expiring within 30 days")
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(viewModel.expiringSoon.prefix(4)) { chem in
|
||||||
|
let days = viewModel.daysUntil(chem.expirationDate ?? "")
|
||||||
|
ChemAlertRow(
|
||||||
|
name: chem.chemicalName,
|
||||||
|
subtitle: "\(chem.lab) · \(chem.storageLocation)",
|
||||||
|
badge: days <= 0 ? "Expired \(-days)d ago" : "In \(days)d",
|
||||||
|
badgeColor: days <= 0 ? .red : .orange
|
||||||
|
)
|
||||||
|
if chem.id != viewModel.expiringSoon.prefix(4).last?.id {
|
||||||
|
Divider().padding(.leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recent activity
|
||||||
|
|
||||||
|
private struct RecentActivitySection: View {
|
||||||
|
let viewModel: DashboardViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
DashboardCard(title: "Recent Activity", icon: "clock.arrow.circlepath") {
|
||||||
|
if viewModel.recentActivity.isEmpty {
|
||||||
|
EmptyStateRow(message: "No recent activity")
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(viewModel.recentActivity) { chem in
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.foregroundStyle(Color(.brandPrimary))
|
||||||
|
.font(.title3)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Added \(chem.chemicalName) to inventory")
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(viewModel.relativeTime(chem.createdAt))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
if chem.id != viewModel.recentActivity.last?.id {
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared sub-components
|
||||||
|
|
||||||
|
private struct DashboardCard<Content: View>: View {
|
||||||
|
let title: String
|
||||||
|
let icon: String
|
||||||
|
var iconColor: Color = Color(.brandPrimary)
|
||||||
|
@ViewBuilder let content: () -> Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(iconColor)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color(UIColor.secondarySystemGroupedBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ChemAlertRow: View {
|
||||||
|
let name: String
|
||||||
|
let subtitle: String
|
||||||
|
let badge: String
|
||||||
|
let badgeColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(name)
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(badge)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(badgeColor.opacity(0.12))
|
||||||
|
.foregroundStyle(badgeColor)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EmptyStateRow: View {
|
||||||
|
let message: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
DashboardView()
|
DashboardView()
|
||||||
}
|
}
|
||||||
|
|||||||
119
LabWise/InventoryView.swift
Normal file
119
LabWise/InventoryView.swift
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import LabWiseKit
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class InventoryViewModel {
|
||||||
|
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 {
|
||||||
|
print("[InventoryViewModel] loadChemicals error: \(error)")
|
||||||
|
errorMessage = "Failed to load chemicals: \(error)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(chemical: Chemical) async {
|
||||||
|
do {
|
||||||
|
try await client.delete(id: chemical.id)
|
||||||
|
chemicals.removeAll { $0.id == chemical.id }
|
||||||
|
} catch {
|
||||||
|
print("[InventoryViewModel] delete error: \(error)")
|
||||||
|
errorMessage = "Failed to delete: \(error)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InventoryView: View {
|
||||||
|
@State private var viewModel = InventoryViewModel()
|
||||||
|
@State private var showAddSheet = false
|
||||||
|
@State private var showScanSheet = false
|
||||||
|
@State private var addMode: AddMode?
|
||||||
|
|
||||||
|
enum AddMode: String, Identifiable {
|
||||||
|
case manual, scan
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if viewModel.isLoading && viewModel.chemicals.isEmpty {
|
||||||
|
ProgressView("Loading...")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if viewModel.chemicals.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Chemicals",
|
||||||
|
systemImage: "flask",
|
||||||
|
description: Text("Add your first chemical using the + button.")
|
||||||
|
)
|
||||||
|
} 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("Inventory")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
addMode = .scan
|
||||||
|
} label: {
|
||||||
|
Label("Scan Label", systemImage: "camera.fill")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
addMode = .manual
|
||||||
|
} label: {
|
||||||
|
Label("Manual Entry", systemImage: "square.and.pencil")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
|
||||||
|
Button("OK") { viewModel.errorMessage = nil }
|
||||||
|
} message: {
|
||||||
|
Text(viewModel.errorMessage ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $addMode) { mode in
|
||||||
|
switch mode {
|
||||||
|
case .manual:
|
||||||
|
AddChemicalView { saved in
|
||||||
|
addMode = nil
|
||||||
|
if saved {
|
||||||
|
Task { await viewModel.loadChemicals() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .scan:
|
||||||
|
ScanView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadChemicals()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,9 @@ public final class APIClient: Sendable {
|
|||||||
req.httpMethod = method
|
req.httpMethod = method
|
||||||
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
let cookieNames = HTTPCookieStorage.shared.cookies(for: url)?.map(\.name) ?? []
|
||||||
|
print("[APIClient] \(method) \(path) — cookies: \(cookieNames)")
|
||||||
|
|
||||||
if let body {
|
if let body {
|
||||||
do {
|
do {
|
||||||
req.httpBody = try JSONEncoder.api.encode(body)
|
req.httpBody = try JSONEncoder.api.encode(body)
|
||||||
@@ -53,19 +56,31 @@ public final class APIClient: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (data, response) = try await session.data(for: req)
|
let (data, response): (Data, URLResponse)
|
||||||
|
do {
|
||||||
|
(data, response) = try await session.data(for: req)
|
||||||
|
} catch {
|
||||||
|
print("[APIClient] Network error on \(method) \(path): \(error)")
|
||||||
|
throw APIError.networkError(error)
|
||||||
|
}
|
||||||
|
|
||||||
guard let http = response as? HTTPURLResponse else {
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
print("[APIClient] Non-HTTP response on \(method) \(path)")
|
||||||
throw APIError.networkError(URLError(.badServerResponse))
|
throw APIError.networkError(URLError(.badServerResponse))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print("[APIClient] \(method) \(path) → \(http.statusCode)")
|
||||||
|
|
||||||
if http.statusCode == 401 {
|
if http.statusCode == 401 {
|
||||||
// Clear cookies and signal the app
|
print("[APIClient] 401 — clearing session and signalling unauthorized")
|
||||||
clearSessionCookies()
|
clearSessionCookies()
|
||||||
onUnauthorized?()
|
onUnauthorized?()
|
||||||
throw APIError.unauthorized
|
throw APIError.unauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
guard (200..<300).contains(http.statusCode) else {
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? "<binary \(data.count)b>"
|
||||||
|
print("[APIClient] HTTP \(http.statusCode) on \(method) \(path): \(body)")
|
||||||
throw APIError.httpError(statusCode: http.statusCode, data: data)
|
throw APIError.httpError(statusCode: http.statusCode, data: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,20 +151,25 @@ public final class APIClient: Sendable {
|
|||||||
do {
|
do {
|
||||||
return try decoder.decode(type, from: data)
|
return try decoder.decode(type, from: data)
|
||||||
} catch {
|
} catch {
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? "<binary \(data.count)b>"
|
||||||
|
print("[APIClient] Decoding \(T.self) failed: \(error)\nResponse body: \(body)")
|
||||||
throw APIError.decodingError(error)
|
throw APIError.decodingError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearSessionCookies() {
|
func clearSessionCookies() {
|
||||||
let storage = HTTPCookieStorage.shared
|
let storage = HTTPCookieStorage.shared
|
||||||
|
// Clear all cookies for the server domain
|
||||||
if let cookies = storage.cookies(for: baseURL) {
|
if let cookies = storage.cookies(for: baseURL) {
|
||||||
for cookie in cookies {
|
for cookie in cookies {
|
||||||
storage.deleteCookie(cookie)
|
storage.deleteCookie(cookie)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Also clear the better-auth session cookie by name
|
// Also sweep all cookies for either session token name variant
|
||||||
if let allCookies = storage.cookies {
|
if let allCookies = storage.cookies {
|
||||||
for cookie in allCookies where cookie.name == "better-auth.session_token" {
|
for cookie in allCookies where
|
||||||
|
cookie.name == "better-auth.session_token" ||
|
||||||
|
cookie.name == "__Secure-better-auth.session_token" {
|
||||||
storage.deleteCookie(cookie)
|
storage.deleteCookie(cookie)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,13 +172,16 @@ public final class AuthClient: Sendable {
|
|||||||
|
|
||||||
private func injectSessionCookie(token: String) {
|
private func injectSessionCookie(token: String) {
|
||||||
guard let serverURL = URL(string: baseURL) else { return }
|
guard let serverURL = URL(string: baseURL) else { return }
|
||||||
|
// Set expiry 30 days out so the cookie survives app restarts.
|
||||||
|
// (Without an explicit expiry iOS treats it as a session cookie and clears it on termination.)
|
||||||
|
let expiry = Date().addingTimeInterval(30 * 24 * 60 * 60)
|
||||||
let properties: [HTTPCookiePropertyKey: Any] = [
|
let properties: [HTTPCookiePropertyKey: Any] = [
|
||||||
.name: "__Secure-better-auth.session_token",
|
.name: "__Secure-better-auth.session_token",
|
||||||
.value: token,
|
.value: token,
|
||||||
.domain: serverURL.host ?? "labwise.wahwa.com",
|
.domain: serverURL.host ?? "labwise.wahwa.com",
|
||||||
.path: "/",
|
.path: "/",
|
||||||
.secure: "TRUE",
|
.secure: "TRUE",
|
||||||
// Session cookie — no explicit expiry; persists until the app clears it.
|
.expires: expiry,
|
||||||
]
|
]
|
||||||
if let cookie = HTTPCookie(properties: properties) {
|
if let cookie = HTTPCookie(properties: properties) {
|
||||||
HTTPCookieStorage.shared.setCookie(cookie)
|
HTTPCookieStorage.shared.setCookie(cookie)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct Chemical: Codable, Identifiable, Sendable {
|
public struct Chemical: Identifiable, Sendable, Encodable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public var piFirstName: String
|
public var piFirstName: String
|
||||||
public var physicalState: String
|
public var physicalState: String
|
||||||
@@ -29,6 +29,15 @@ public struct Chemical: Codable, Identifiable, Sendable {
|
|||||||
public var createdAt: String?
|
public var createdAt: String?
|
||||||
public var updatedAt: String?
|
public var updatedAt: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, piFirstName, physicalState, chemicalName, bldgCode, lab
|
||||||
|
case storageLocation, storageDevice, numberOfContainers, amountPerContainer
|
||||||
|
case unitOfMeasure, casNumber, chemicalFormula, molecularWeight, vendor
|
||||||
|
case catalogNumber, lotNumber, expirationDate, concentration, percentageFull
|
||||||
|
case comments, barcode, contact, scannedImage, needsManualEntry
|
||||||
|
case createdAt, updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String = "",
|
id: String = "",
|
||||||
piFirstName: String = "",
|
piFirstName: String = "",
|
||||||
@@ -88,6 +97,47 @@ public struct Chemical: Codable, Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Chemical: Decodable {
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
piFirstName = try c.decode(String.self, forKey: .piFirstName)
|
||||||
|
physicalState = try c.decode(String.self, forKey: .physicalState)
|
||||||
|
chemicalName = try c.decode(String.self, forKey: .chemicalName)
|
||||||
|
bldgCode = try c.decode(String.self, forKey: .bldgCode)
|
||||||
|
lab = try c.decode(String.self, forKey: .lab)
|
||||||
|
storageLocation = try c.decode(String.self, forKey: .storageLocation)
|
||||||
|
storageDevice = try c.decode(String.self, forKey: .storageDevice)
|
||||||
|
numberOfContainers = try c.decode(String.self, forKey: .numberOfContainers)
|
||||||
|
amountPerContainer = try c.decode(String.self, forKey: .amountPerContainer)
|
||||||
|
unitOfMeasure = try c.decode(String.self, forKey: .unitOfMeasure)
|
||||||
|
casNumber = try c.decode(String.self, forKey: .casNumber)
|
||||||
|
chemicalFormula = try c.decodeIfPresent(String.self, forKey: .chemicalFormula)
|
||||||
|
molecularWeight = try c.decodeIfPresent(String.self, forKey: .molecularWeight)
|
||||||
|
vendor = try c.decodeIfPresent(String.self, forKey: .vendor)
|
||||||
|
catalogNumber = try c.decodeIfPresent(String.self, forKey: .catalogNumber)
|
||||||
|
lotNumber = try c.decodeIfPresent(String.self, forKey: .lotNumber)
|
||||||
|
expirationDate = try c.decodeIfPresent(String.self, forKey: .expirationDate)
|
||||||
|
concentration = try c.decodeIfPresent(String.self, forKey: .concentration)
|
||||||
|
comments = try c.decodeIfPresent(String.self, forKey: .comments)
|
||||||
|
barcode = try c.decodeIfPresent(String.self, forKey: .barcode)
|
||||||
|
contact = try c.decodeIfPresent(String.self, forKey: .contact)
|
||||||
|
scannedImage = try c.decodeIfPresent(String.self, forKey: .scannedImage)
|
||||||
|
needsManualEntry = try c.decodeIfPresent([String].self, forKey: .needsManualEntry)
|
||||||
|
createdAt = try c.decodeIfPresent(String.self, forKey: .createdAt)
|
||||||
|
updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
|
||||||
|
|
||||||
|
// percentageFull arrives as a number OR a numeric string ("12.00") depending on the query path
|
||||||
|
if let d = try? c.decodeIfPresent(Double.self, forKey: .percentageFull) {
|
||||||
|
percentageFull = d
|
||||||
|
} else if let s = try? c.decodeIfPresent(String.self, forKey: .percentageFull) {
|
||||||
|
percentageFull = Double(s)
|
||||||
|
} else {
|
||||||
|
percentageFull = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct ChemicalCreateBody: Codable, Sendable {
|
public struct ChemicalCreateBody: Codable, Sendable {
|
||||||
public var piFirstName: String
|
public var piFirstName: String
|
||||||
public var physicalState: String
|
public var physicalState: String
|
||||||
|
|||||||
Reference in New Issue
Block a user