diff --git a/LabWise/AddChemicalView.swift b/LabWise/AddChemicalView.swift new file mode 100644 index 0000000..5d7f58d --- /dev/null +++ b/LabWise/AddChemicalView.swift @@ -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, 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)) + } + } +} diff --git a/LabWise/AppState.swift b/LabWise/AppState.swift index 2d79b6a..a8f27f7 100644 --- a/LabWise/AppState.swift +++ b/LabWise/AppState.swift @@ -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 diff --git a/LabWise/ChemicalDetailView.swift b/LabWise/ChemicalDetailView.swift index 9284c06..669adc6 100644 --- a/LabWise/ChemicalDetailView.swift +++ b/LabWise/ChemicalDetailView.swift @@ -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 + } } } diff --git a/LabWise/ChemicalsListView.swift b/LabWise/ChemicalsListView.swift index fba85b7..e2b3b81 100644 --- a/LabWise/ChemicalsListView.swift +++ b/LabWise/ChemicalsListView.swift @@ -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 diff --git a/LabWise/DashboardView.swift b/LabWise/DashboardView.swift index fcd1015..4fc02c1 100644 --- a/LabWise/DashboardView.swift +++ b/LabWise/DashboardView.swift @@ -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: 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() } diff --git a/LabWise/InventoryView.swift b/LabWise/InventoryView.swift new file mode 100644 index 0000000..593ac7e --- /dev/null +++ b/LabWise/InventoryView.swift @@ -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() + } + } +} diff --git a/LabWiseKit/Sources/LabWiseKit/APIClient.swift b/LabWiseKit/Sources/LabWiseKit/APIClient.swift index 7c131cf..64e6110 100644 --- a/LabWiseKit/Sources/LabWiseKit/APIClient.swift +++ b/LabWiseKit/Sources/LabWiseKit/APIClient.swift @@ -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) ?? "" + 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) ?? "" + 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) } } diff --git a/LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift b/LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift index 25ba89c..894e689 100644 --- a/LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift +++ b/LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift @@ -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) diff --git a/LabWiseKit/Sources/LabWiseKit/Models/Chemical.swift b/LabWiseKit/Sources/LabWiseKit/Models/Chemical.swift index 8b9562b..f93c3ed 100644 --- a/LabWiseKit/Sources/LabWiseKit/Models/Chemical.swift +++ b/LabWiseKit/Sources/LabWiseKit/Models/Chemical.swift @@ -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