Files
LabWiseiOS/LabWise/ChemicalDetailView.swift

209 lines
7.7 KiB
Swift
Raw Normal View History

import SwiftUI
import LabWiseKit
private let isoDateFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withFullDate]
return f
}()
private let isoTimestampFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private let displayDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .none
return f
}()
private let displayTimestampFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
private func formatDate(_ iso: String?) -> String {
guard let iso else { return "" }
if let date = isoDateFormatter.date(from: iso) {
return displayDateFormatter.string(from: date)
}
// Fallback: try full timestamp (e.g. "2025-03-01T00:00:00.000Z")
if let date = isoTimestampFormatter.date(from: iso) {
return displayDateFormatter.string(from: date)
}
return iso
}
private func formatTimestamp(_ iso: String?) -> String {
guard let iso else { return "" }
if let date = isoTimestampFormatter.date(from: iso) {
return displayTimestampFormatter.string(from: date)
}
return iso
}
struct ChemicalDetailView: View {
2026-03-20 02:30:15 -05:00
@State private var chemical: Chemical
@State private var showEdit = false
init(chemical: Chemical) {
self._chemical = State(initialValue: chemical)
}
var body: some View {
Form {
2026-05-01 13:54:05 -05:00
let missing = chemical.missingFields
if !missing.isEmpty {
Section {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Missing \(missing.count) required \(missing.count == 1 ? "field" : "fields")")
.font(.subheadline.weight(.semibold))
Text(missing.joined(separator: ", "))
.font(.caption)
.foregroundStyle(.secondary)
}
}
Text("Tap Edit to fill them in.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
2026-05-01 13:54:05 -05:00
Section("Identity") {
row("Chemical Name", chemical.chemicalName, required: true)
row("CAS #", chemical.casNumber, required: true)
row("Formula", chemical.chemicalFormula)
row("Molecular Weight", chemical.molecularWeight)
row("Physical State", chemical.physicalState?.capitalized, required: true)
row("Concentration", chemical.concentration)
}
Section("Storage") {
2026-05-01 13:54:05 -05:00
row("Storage Location", chemical.storageLocation, required: true)
row("Storage Device", chemical.storageDevice, required: true)
row("Building Code", chemical.bldgCode, required: true)
row("Lab", chemical.lab, required: true)
row("# of Containers", chemical.numberOfContainers, required: true)
amountRow
if let pct = chemical.percentageFull {
LabeledContent("% Full") {
HStack(spacing: 8) {
PercentageBar(value: pct / 100)
.frame(width: 80, height: 6)
Text("\(Int(pct))%")
.font(.callout)
}
}
}
}
Section("Vendor") {
2026-05-01 13:54:05 -05:00
row("PI First Name", chemical.piFirstName, required: true)
row("Vendor", chemical.vendor)
row("Catalog #", chemical.catalogNumber)
row("Lot #", chemical.lotNumber)
if let exp = chemical.expirationDate, !exp.isEmpty {
LabeledContent("Expiration", value: formatDate(exp))
}
2026-05-01 13:54:05 -05:00
row("Barcode", chemical.barcode)
row("Contact", chemical.contact)
}
if let comments = chemical.comments, !comments.isEmpty {
Section("Comments") {
Text(comments)
.font(.body)
}
}
Section("Record") {
if let created = chemical.createdAt {
LabeledContent("Created", value: formatTimestamp(created))
}
if let updated = chemical.updatedAt {
LabeledContent("Updated", value: formatTimestamp(updated))
}
}
}
2026-05-01 13:54:05 -05:00
.navigationTitle(chemical.displayName)
.navigationBarTitleDisplayMode(.inline)
2026-03-20 02:30:15 -05:00
.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
}
}
2026-05-01 13:54:05 -05:00
/// Combines "<amount> <unit>" both required, so we show a Missing
/// placeholder if either side is empty. Yellow-tinted when incomplete.
@ViewBuilder
private var amountRow: some View {
let amt = (chemical.amountPerContainer ?? "").trimmingCharacters(in: .whitespaces)
let unit = (chemical.unitOfMeasure ?? "").trimmingCharacters(in: .whitespaces)
if amt.isEmpty || unit.isEmpty {
let parts: [String] = [
amt.isEmpty ? "Amount" : nil,
unit.isEmpty ? "Unit" : nil,
].compactMap { $0 }
LabeledContent("Amount / Container") {
Text("Missing \(parts.joined(separator: " & "))")
.italic()
.foregroundStyle(.orange)
}
.listRowBackground(Color.orange.opacity(0.12))
} else {
LabeledContent("Amount / Container", value: "\(amt) \(unit)")
}
}
/// Render a labelled row.
/// - Optional fields are hidden when empty (matches the web `<Field>` helper).
/// - Required fields always render: present value normally, missing value
/// with a yellow row background and an italic "Missing" placeholder so
/// the user can see exactly which field needs attention and where it lives.
@ViewBuilder
private func row(_ label: String, _ value: String?, required: Bool = false) -> some View {
let trimmed = (value ?? "").trimmingCharacters(in: .whitespaces)
if !trimmed.isEmpty {
LabeledContent(label, value: trimmed)
} else if required {
LabeledContent(label) {
Text("Missing")
.italic()
.foregroundStyle(.orange)
}
.listRowBackground(Color.orange.opacity(0.12))
}
}
}