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