Manual Entry kinda works

This commit is contained in:
2026-03-20 02:30:15 -05:00
parent 2110c13ea1
commit 310d9faf33
9 changed files with 1029 additions and 88 deletions

View 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))
}
}
}

View File

@@ -21,7 +21,8 @@ final class AppState {
// 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"
($0.name == "__Secure-better-auth.session_token" || $0.name == "better-auth.session_token")
&& !$0.value.isEmpty
} ?? false
if hasCookie {
// Optimistically show the authenticated UI, then validate

View File

@@ -2,7 +2,12 @@ import SwiftUI
import LabWiseKit
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 {
Form {
@@ -57,6 +62,9 @@ struct ChemicalDetailView: View {
if let barcode = chemical.barcode {
LabeledContent("Barcode", value: barcode)
}
if let contact = chemical.contact {
LabeledContent("Contact", value: contact)
}
}
if let comments = chemical.comments, !comments.isEmpty {
@@ -77,5 +85,30 @@ struct ChemicalDetailView: View {
}
.navigationTitle(chemical.chemicalName)
.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
}
}
}

View File

@@ -1,81 +1,7 @@
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()
}
}
}
// MARK: - Chemical row
struct ChemicalRowView: View {
let chemical: Chemical
@@ -101,6 +27,8 @@ struct ChemicalRowView: View {
}
}
// MARK: - Physical state badge
struct PhysicalStateBadge: View {
let state: String
@@ -124,8 +52,10 @@ struct PhysicalStateBadge: View {
}
}
// MARK: - Percentage bar
struct PercentageBar: View {
let value: Double // 0.0 - 1.0
let value: Double // 0.0 1.0
var body: some View {
GeometryReader { geo in

View File

@@ -1,13 +1,16 @@
import SwiftUI
import LabWiseKit
// MARK: - Tab shell
struct DashboardView: View {
var body: some View {
TabView {
Tab("Chemicals", systemImage: "flask.fill") {
ChemicalsListView()
Tab("Dashboard", systemImage: "chart.bar.fill") {
DashboardHomeView()
}
Tab("Scan", systemImage: "camera.fill") {
ScanView()
Tab("Inventory", systemImage: "flask.fill") {
InventoryView()
}
Tab("Profile", systemImage: "person.fill") {
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 {
DashboardView()
}

119
LabWise/InventoryView.swift Normal file
View 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()
}
}
}

View File

@@ -45,6 +45,9 @@ public final class APIClient: Sendable {
req.httpMethod = method
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
let cookieNames = HTTPCookieStorage.shared.cookies(for: url)?.map(\.name) ?? []
print("[APIClient] \(method) \(path) — cookies: \(cookieNames)")
if let body {
do {
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 {
print("[APIClient] Non-HTTP response on \(method) \(path)")
throw APIError.networkError(URLError(.badServerResponse))
}
print("[APIClient] \(method) \(path)\(http.statusCode)")
if http.statusCode == 401 {
// Clear cookies and signal the app
print("[APIClient] 401 — clearing session and signalling unauthorized")
clearSessionCookies()
onUnauthorized?()
throw APIError.unauthorized
}
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)
}
@@ -136,20 +151,25 @@ public final class APIClient: Sendable {
do {
return try decoder.decode(type, from: data)
} 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)
}
}
func clearSessionCookies() {
let storage = HTTPCookieStorage.shared
// Clear all cookies for the server domain
if let cookies = storage.cookies(for: baseURL) {
for cookie in cookies {
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 {
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)
}
}

View File

@@ -172,13 +172,16 @@ public final class AuthClient: Sendable {
private func injectSessionCookie(token: String) {
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] = [
.name: "__Secure-better-auth.session_token",
.value: token,
.domain: serverURL.host ?? "labwise.wahwa.com",
.path: "/",
.secure: "TRUE",
// Session cookie no explicit expiry; persists until the app clears it.
.expires: expiry,
]
if let cookie = HTTPCookie(properties: properties) {
HTTPCookieStorage.shared.setCookie(cookie)

View File

@@ -1,6 +1,6 @@
import Foundation
public struct Chemical: Codable, Identifiable, Sendable {
public struct Chemical: Identifiable, Sendable, Encodable {
public let id: String
public var piFirstName: String
public var physicalState: String
@@ -29,6 +29,15 @@ public struct Chemical: Codable, Identifiable, Sendable {
public var createdAt: 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(
id: 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 var piFirstName: String
public var physicalState: String