More UI Changes? Not sure why they didn't go previously
This commit is contained in:
@@ -6,6 +6,10 @@
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
869A22CA2F6D022900C58926 /* LabWiseKit in Frameworks */ = {isa = PBXBuildFile; productRef = 869A22C92F6D022900C58926 /* LabWiseKit */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
86FE2C1A2F6CDAF200FAC3D8 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
@@ -24,14 +28,28 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
868B57E42F6D017F00729F36 /* LabWiseKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = LabWiseKit; sourceTree = SOURCE_ROOT; };
|
||||
86FE2C0C2F6CDAF000FAC3D8 /* LabWise.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LabWise.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
86FE2C192F6CDAF200FAC3D8 /* LabWiseTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LabWiseTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
86FE2C232F6CDAF200FAC3D8 /* LabWiseUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LabWiseUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
869A22DA2F6D086E00C58926 /* Exceptions for "LabWise" folder in "LabWise" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 86FE2C0B2F6CDAF000FAC3D8 /* LabWise */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
86FE2C0E2F6CDAF000FAC3D8 /* LabWise */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
869A22DA2F6D086E00C58926 /* Exceptions for "LabWise" folder in "LabWise" target */,
|
||||
);
|
||||
path = LabWise;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -52,6 +70,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
869A22CA2F6D022900C58926 /* LabWiseKit in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -72,6 +91,13 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
869A22C82F6D022900C58926 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
86FE2C032F6CDAF000FAC3D8 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -79,6 +105,8 @@
|
||||
86FE2C1C2F6CDAF200FAC3D8 /* LabWiseTests */,
|
||||
86FE2C262F6CDAF200FAC3D8 /* LabWiseUITests */,
|
||||
86FE2C0D2F6CDAF000FAC3D8 /* Products */,
|
||||
868B57E42F6D017F00729F36 /* LabWiseKit */,
|
||||
869A22C82F6D022900C58926 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -112,6 +140,7 @@
|
||||
);
|
||||
name = LabWise;
|
||||
packageProductDependencies = (
|
||||
869A22C92F6D022900C58926 /* LabWiseKit */,
|
||||
);
|
||||
productName = LabWise;
|
||||
productReference = 86FE2C0C2F6CDAF000FAC3D8 /* LabWise.app */;
|
||||
@@ -195,6 +224,9 @@
|
||||
);
|
||||
mainGroup = 86FE2C032F6CDAF000FAC3D8;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
868B57E32F6D007100729F36 /* XCLocalSwiftPackageReference "LabWiseKit" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 86FE2C0D2F6CDAF000FAC3D8 /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -400,6 +432,7 @@
|
||||
DEVELOPMENT_TEAM = YK2DB9NT3S;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = LabWise/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "Camera Access needed for scanning chemical labels for adding to inventory.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -435,6 +468,7 @@
|
||||
DEVELOPMENT_TEAM = YK2DB9NT3S;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = LabWise/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "Camera Access needed for scanning chemical labels for adding to inventory.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -584,6 +618,20 @@
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
868B57E32F6D007100729F36 /* XCLocalSwiftPackageReference "LabWiseKit" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = LabWiseKit;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
869A22C92F6D022900C58926 /* LabWiseKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = LabWiseKit;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 86FE2C042F6CDAF000FAC3D8 /* Project object */;
|
||||
}
|
||||
|
||||
58
LabWise/AppState.swift
Normal file
58
LabWise/AppState.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import SwiftUI
|
||||
import LabWiseKit
|
||||
|
||||
@Observable
|
||||
final class AppState {
|
||||
var isAuthenticated: Bool = false
|
||||
var currentUser: AuthUser?
|
||||
|
||||
static let shared = AppState()
|
||||
|
||||
private let authClient = AuthClient()
|
||||
|
||||
private init() {
|
||||
// Wire 401 handler
|
||||
APIClient.shared.onUnauthorized = { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.signedOut()
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
} ?? false
|
||||
if hasCookie {
|
||||
// Optimistically show the authenticated UI, then validate
|
||||
isAuthenticated = true
|
||||
Task { @MainActor [weak self] in
|
||||
await self?.validateExistingSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func validateExistingSession() async {
|
||||
do {
|
||||
let user = try await authClient.fetchCurrentUser()
|
||||
currentUser = user
|
||||
isAuthenticated = true
|
||||
} catch {
|
||||
// Cookie was stale or invalid
|
||||
signedOut()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func signedIn(user: AuthUser) {
|
||||
currentUser = user
|
||||
isAuthenticated = true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func signedOut() {
|
||||
currentUser = nil
|
||||
isAuthenticated = false
|
||||
}
|
||||
}
|
||||
81
LabWise/ChemicalDetailView.swift
Normal file
81
LabWise/ChemicalDetailView.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
import SwiftUI
|
||||
import LabWiseKit
|
||||
|
||||
struct ChemicalDetailView: View {
|
||||
let chemical: Chemical
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Identity") {
|
||||
LabeledContent("Name", value: chemical.chemicalName)
|
||||
LabeledContent("CAS Number", value: chemical.casNumber)
|
||||
if let formula = chemical.chemicalFormula {
|
||||
LabeledContent("Formula", value: formula)
|
||||
}
|
||||
if let mw = chemical.molecularWeight {
|
||||
LabeledContent("Molecular Weight", value: mw)
|
||||
}
|
||||
LabeledContent("Physical State", value: chemical.physicalState.capitalized)
|
||||
if let conc = chemical.concentration {
|
||||
LabeledContent("Concentration", value: conc)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Storage") {
|
||||
LabeledContent("Location", value: chemical.storageLocation)
|
||||
LabeledContent("Device", value: chemical.storageDevice)
|
||||
LabeledContent("Building", value: chemical.bldgCode)
|
||||
LabeledContent("Lab", value: chemical.lab)
|
||||
LabeledContent("Containers", value: chemical.numberOfContainers)
|
||||
LabeledContent("Amount / Container", value: "\(chemical.amountPerContainer) \(chemical.unitOfMeasure)")
|
||||
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") {
|
||||
LabeledContent("PI", value: chemical.piFirstName)
|
||||
if let vendor = chemical.vendor {
|
||||
LabeledContent("Vendor", value: vendor)
|
||||
}
|
||||
if let catalog = chemical.catalogNumber {
|
||||
LabeledContent("Catalog #", value: catalog)
|
||||
}
|
||||
if let lot = chemical.lotNumber {
|
||||
LabeledContent("Lot #", value: lot)
|
||||
}
|
||||
if let exp = chemical.expirationDate {
|
||||
LabeledContent("Expiration", value: exp)
|
||||
}
|
||||
if let barcode = chemical.barcode {
|
||||
LabeledContent("Barcode", value: barcode)
|
||||
}
|
||||
}
|
||||
|
||||
if let comments = chemical.comments, !comments.isEmpty {
|
||||
Section("Comments") {
|
||||
Text(comments)
|
||||
.font(.body)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Record") {
|
||||
if let created = chemical.createdAt {
|
||||
LabeledContent("Created", value: created)
|
||||
}
|
||||
if let updated = chemical.updatedAt {
|
||||
LabeledContent("Updated", value: updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(chemical.chemicalName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
147
LabWise/ChemicalsListView.swift
Normal file
147
LabWise/ChemicalsListView.swift
Normal file
@@ -0,0 +1,147 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChemicalRowView: View {
|
||||
let chemical: Chemical
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(chemical.chemicalName)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
PhysicalStateBadge(state: chemical.physicalState)
|
||||
}
|
||||
Text("CAS: \(chemical.casNumber)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let pct = chemical.percentageFull {
|
||||
PercentageBar(value: pct / 100)
|
||||
.frame(height: 4)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
struct PhysicalStateBadge: View {
|
||||
let state: String
|
||||
|
||||
var color: Color {
|
||||
switch state.lowercased() {
|
||||
case "liquid": return Color(.brandPrimary)
|
||||
case "solid": return Color(red: 0.42, green: 0.30, blue: 0.18)
|
||||
case "gas": return Color(red: 0.22, green: 0.56, blue: 0.52)
|
||||
default: return Color(.brandMutedForeground)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(state.capitalized)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(color.opacity(0.15))
|
||||
.foregroundStyle(color)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
struct PercentageBar: View {
|
||||
let value: Double // 0.0 - 1.0
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(barColor)
|
||||
.frame(width: geo.size.width * max(0, min(1, value)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var barColor: Color {
|
||||
if value > 0.6 { return .green }
|
||||
if value > 0.25 { return .yellow }
|
||||
return .red
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ struct DashboardView: View {
|
||||
ProfileView()
|
||||
}
|
||||
}
|
||||
.tint(Color("Brand/BrandPrimary"))
|
||||
.tint(Color(.brandPrimary))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
19
LabWise/Info.plist
Normal file
19
LabWise/Info.plist
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.adipu.labwise</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>labwise</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,17 +1,19 @@
|
||||
//
|
||||
// LabWiseApp.swift
|
||||
// LabWise
|
||||
//
|
||||
// Created by Aditya Pulipaka on 3/19/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import LabWiseKit
|
||||
|
||||
@main
|
||||
struct LabWiseApp: App {
|
||||
@State private var appState = AppState.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
if appState.isAuthenticated {
|
||||
DashboardView()
|
||||
.environment(appState)
|
||||
} else {
|
||||
LoginView()
|
||||
.environment(appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ struct LoginView: View {
|
||||
.frame(height: 52)
|
||||
Text("Chemical Inventory Management")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color("Brand/BrandMutedForeground"))
|
||||
.foregroundStyle(Color(.brandMutedForeground))
|
||||
}
|
||||
|
||||
// Card
|
||||
@@ -46,7 +46,7 @@ struct LoginView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(Color("Brand/BrandPrimary").opacity(0.2), lineWidth: 1)
|
||||
.strokeBorder(Color(.brandPrimary).opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
|
||||
SecureField("Password", text: $password)
|
||||
@@ -57,7 +57,7 @@ struct LoginView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(Color("Brand/BrandPrimary").opacity(0.2), lineWidth: 1)
|
||||
.strokeBorder(Color(.brandPrimary).opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ struct LoginView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
}
|
||||
.background(Color("Brand/BrandPrimary"))
|
||||
.background(Color(.brandPrimary))
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.disabled(email.isEmpty || password.isEmpty || isLoading || isGoogleLoading)
|
||||
@@ -91,13 +91,13 @@ struct LoginView: View {
|
||||
|
||||
HStack {
|
||||
Rectangle()
|
||||
.fill(Color("Brand/BrandPrimary").opacity(0.15))
|
||||
.fill(Color(.brandPrimary).opacity(0.15))
|
||||
.frame(height: 1)
|
||||
Text("or")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Color("Brand/BrandMutedForeground"))
|
||||
.foregroundStyle(Color(.brandMutedForeground))
|
||||
Rectangle()
|
||||
.fill(Color("Brand/BrandPrimary").opacity(0.15))
|
||||
.fill(Color(.brandPrimary).opacity(0.15))
|
||||
.frame(height: 1)
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ struct LoginView: View {
|
||||
.padding(24)
|
||||
.background(Color(UIColor.secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.shadow(color: Color("Brand/BrandPrimary").opacity(0.06), radius: 12, x: 0, y: 4)
|
||||
.shadow(color: Color(.brandPrimary).opacity(0.06), radius: 12, x: 0, y: 4)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
@@ -174,8 +174,8 @@ struct GoogleSignInButton: View {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Image(systemName: "globe")
|
||||
.foregroundStyle(Color("Brand/BrandPrimary"))
|
||||
GoogleGLogo()
|
||||
.frame(width: 18, height: 18)
|
||||
Text("Continue with Google")
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(Color(UIColor.label))
|
||||
@@ -187,13 +187,23 @@ struct GoogleSignInButton: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(Color("Brand/BrandPrimary").opacity(0.25), lineWidth: 1)
|
||||
.strokeBorder(Color(.brandPrimary).opacity(0.25), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// The four-colour Google "G" logo, matching the web app implementation.
|
||||
struct GoogleGLogo: View {
|
||||
var body: some View {
|
||||
Image("GoogleLogo")
|
||||
.resizable()
|
||||
.renderingMode(.original)
|
||||
.scaledToFit()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
LoginView()
|
||||
.environment(AppState.shared)
|
||||
|
||||
144
LabWise/ProfileView.swift
Normal file
144
LabWise/ProfileView.swift
Normal file
@@ -0,0 +1,144 @@
|
||||
import SwiftUI
|
||||
import LabWiseKit
|
||||
|
||||
@Observable
|
||||
final class ProfileViewModel {
|
||||
var profile: UserProfile?
|
||||
var isLoading = false
|
||||
var isSaving = false
|
||||
var isEditing = false
|
||||
var errorMessage: String?
|
||||
|
||||
// Editable fields
|
||||
var piFirstName = ""
|
||||
var bldgCode = ""
|
||||
var lab = ""
|
||||
var contact = ""
|
||||
|
||||
private let profileClient = ProfileClient()
|
||||
private let authClient = AuthClient()
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let p = try await profileClient.get()
|
||||
profile = p
|
||||
populateFields(from: p)
|
||||
} catch {
|
||||
// Profile may not exist yet — that's OK
|
||||
}
|
||||
}
|
||||
|
||||
func save() async {
|
||||
isSaving = true
|
||||
defer { isSaving = false }
|
||||
do {
|
||||
let body = UserProfileUpsertBody(
|
||||
piFirstName: piFirstName,
|
||||
bldgCode: bldgCode,
|
||||
lab: lab,
|
||||
contact: contact.isEmpty ? nil : contact
|
||||
)
|
||||
let updated = try await profileClient.upsert(body)
|
||||
profile = updated
|
||||
populateFields(from: updated)
|
||||
isEditing = false
|
||||
} catch {
|
||||
errorMessage = "Failed to save profile."
|
||||
}
|
||||
}
|
||||
|
||||
func signOut() async {
|
||||
try? await authClient.signOut()
|
||||
await MainActor.run {
|
||||
AppState.shared.signedOut()
|
||||
}
|
||||
}
|
||||
|
||||
private func populateFields(from p: UserProfile) {
|
||||
piFirstName = p.piFirstName
|
||||
bldgCode = p.bldgCode
|
||||
lab = p.lab
|
||||
contact = p.contact ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
struct ProfileView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@State private var viewModel = ProfileViewModel()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
if let user = appState.currentUser {
|
||||
Section("Account") {
|
||||
LabeledContent("Email", value: user.email)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Lab Info") {
|
||||
if viewModel.isEditing {
|
||||
TextField("PI First Name", text: $viewModel.piFirstName)
|
||||
TextField("Building Code", text: $viewModel.bldgCode)
|
||||
TextField("Lab", text: $viewModel.lab)
|
||||
TextField("Contact", text: $viewModel.contact)
|
||||
} else {
|
||||
LabeledContent("PI First Name", value: viewModel.piFirstName.isEmpty ? "—" : viewModel.piFirstName)
|
||||
LabeledContent("Building Code", value: viewModel.bldgCode.isEmpty ? "—" : viewModel.bldgCode)
|
||||
LabeledContent("Lab", value: viewModel.lab.isEmpty ? "—" : viewModel.lab)
|
||||
LabeledContent("Contact", value: viewModel.contact.isEmpty ? "—" : viewModel.contact)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.signOut() }
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Sign Out")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Profile")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if viewModel.isEditing {
|
||||
Button("Save") {
|
||||
Task { await viewModel.save() }
|
||||
}
|
||||
.disabled(viewModel.isSaving)
|
||||
} else {
|
||||
Button("Edit") {
|
||||
viewModel.isEditing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if viewModel.isEditing {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") {
|
||||
viewModel.isEditing = false
|
||||
if let p = viewModel.profile {
|
||||
viewModel.piFirstName = p.piFirstName
|
||||
viewModel.bldgCode = p.bldgCode
|
||||
viewModel.lab = p.lab
|
||||
viewModel.contact = p.contact ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
|
||||
Button("OK") { viewModel.errorMessage = nil }
|
||||
} message: {
|
||||
Text(viewModel.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
609
LabWise/ScanView.swift
Normal file
609
LabWise/ScanView.swift
Normal file
@@ -0,0 +1,609 @@
|
||||
import SwiftUI
|
||||
import VisionKit
|
||||
import LabWiseKit
|
||||
|
||||
// MARK: - Scan stages
|
||||
|
||||
enum ScanStage {
|
||||
case camera
|
||||
case analyzing
|
||||
case review
|
||||
}
|
||||
|
||||
// MARK: - Extracted label data (Foundation Models @Generable output)
|
||||
|
||||
import FoundationModels
|
||||
|
||||
@Generable
|
||||
struct ExtractedLabelData {
|
||||
@Guide(description: "CAS registry number, e.g. 7732-18-5")
|
||||
var casNumber: String
|
||||
|
||||
@Guide(description: "Full chemical name")
|
||||
var chemicalName: String
|
||||
|
||||
@Guide(description: "Chemical formula, e.g. H2O")
|
||||
var chemicalFormula: String
|
||||
|
||||
@Guide(description: "Molecular weight with unit, e.g. 18.015 g/mol")
|
||||
var molecularWeight: String
|
||||
|
||||
@Guide(description: "Vendor or manufacturer name")
|
||||
var vendor: String
|
||||
|
||||
@Guide(description: "Catalog number from vendor")
|
||||
var catalogNumber: String
|
||||
|
||||
@Guide(description: "Lot number")
|
||||
var lotNumber: String
|
||||
|
||||
@Guide(description: "Concentration, e.g. 1M, 98%")
|
||||
var concentration: String
|
||||
|
||||
@Guide(description: "Expiration date in ISO format YYYY-MM-DD if found")
|
||||
var expirationDate: String
|
||||
|
||||
@Guide(description: "Physical state: liquid, solid, or gas")
|
||||
var physicalState: String
|
||||
|
||||
@Guide(description: "Storage device inferred from label: flammable cabinet, fridge, freezer, ambient, etc.")
|
||||
var storageDevice: String
|
||||
|
||||
@Guide(description: "Container type: bottle, cylinder, drum, vial, etc.")
|
||||
var containerType: String
|
||||
}
|
||||
|
||||
// MARK: - ScanViewModel
|
||||
|
||||
@Observable
|
||||
final class ScanViewModel {
|
||||
var stage: ScanStage = .camera
|
||||
var capturedImage: UIImage?
|
||||
var capturedTexts: [String] = []
|
||||
var scannedBarcode: String?
|
||||
|
||||
// Extracted fields
|
||||
var casNumber = ""
|
||||
var chemicalName = ""
|
||||
var chemicalFormula = ""
|
||||
var molecularWeight = ""
|
||||
var vendor = ""
|
||||
var catalogNumber = ""
|
||||
var lotNumber = ""
|
||||
var concentration = ""
|
||||
var expirationDate = ""
|
||||
var physicalState = ""
|
||||
var storageDevice = ""
|
||||
var numberOfContainers = "1"
|
||||
var amountPerContainer = ""
|
||||
var unitOfMeasure = ""
|
||||
var percentageFull: Double = 50
|
||||
|
||||
// Pre-populated from profile
|
||||
var piFirstName = ""
|
||||
var bldgCode = ""
|
||||
var lab = ""
|
||||
var storageLocation = ""
|
||||
var contact = ""
|
||||
|
||||
var analysisError: String?
|
||||
var isSaving = false
|
||||
var saveError: String?
|
||||
var didSave = false
|
||||
|
||||
private let chemicalsClient = ChemicalsClient()
|
||||
private let profileClient = ProfileClient()
|
||||
|
||||
func loadProfile() async {
|
||||
if let profile = try? await profileClient.get() {
|
||||
piFirstName = profile.piFirstName
|
||||
bldgCode = profile.bldgCode
|
||||
lab = profile.lab
|
||||
contact = profile.contact ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
func analyzeTexts() async {
|
||||
stage = .analyzing
|
||||
analysisError = nil
|
||||
|
||||
guard SystemLanguageModel.default.isAvailable else {
|
||||
// Fall through to review with empty fields
|
||||
stage = .review
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let combinedText = capturedTexts.joined(separator: "\n")
|
||||
let session = LanguageModelSession(instructions: """
|
||||
You are a chemical label OCR assistant. Extract structured fields from the raw OCR text of a chemical reagent label.
|
||||
Return only the fields you can identify. Leave fields empty string if not found.
|
||||
For storageDevice infer from hazard symbols: FLAMMABLE → flammable cabinet, KEEP REFRIGERATED → fridge, KEEP FROZEN → freezer.
|
||||
""")
|
||||
let response = try await session.respond(
|
||||
to: "Extract fields from this chemical label text:\n\n\(combinedText)",
|
||||
generating: ExtractedLabelData.self
|
||||
)
|
||||
let data = response.content
|
||||
await MainActor.run {
|
||||
casNumber = data.casNumber
|
||||
chemicalName = data.chemicalName
|
||||
chemicalFormula = data.chemicalFormula
|
||||
molecularWeight = data.molecularWeight
|
||||
vendor = data.vendor
|
||||
catalogNumber = data.catalogNumber
|
||||
lotNumber = data.lotNumber
|
||||
concentration = data.concentration
|
||||
expirationDate = data.expirationDate
|
||||
physicalState = data.physicalState
|
||||
storageDevice = data.storageDevice
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
analysisError = "Label analysis failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run { stage = .review }
|
||||
}
|
||||
|
||||
var missingRequiredFields: [String] {
|
||||
var missing: [String] = []
|
||||
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 chemicalName.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("Chemical Name") }
|
||||
if casNumber.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("CAS Number") }
|
||||
return missing
|
||||
}
|
||||
|
||||
func save() async {
|
||||
isSaving = true
|
||||
saveError = nil
|
||||
defer { isSaving = false }
|
||||
do {
|
||||
let body = ChemicalCreateBody(
|
||||
piFirstName: piFirstName,
|
||||
physicalState: physicalState.isEmpty ? "unknown" : physicalState,
|
||||
chemicalName: chemicalName,
|
||||
bldgCode: bldgCode,
|
||||
lab: lab,
|
||||
storageLocation: storageLocation,
|
||||
storageDevice: storageDevice.isEmpty ? "unknown" : storageDevice,
|
||||
numberOfContainers: numberOfContainers.isEmpty ? "1" : numberOfContainers,
|
||||
amountPerContainer: amountPerContainer.isEmpty ? "unknown" : amountPerContainer,
|
||||
unitOfMeasure: unitOfMeasure.isEmpty ? "unknown" : 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: percentageFull,
|
||||
barcode: scannedBarcode,
|
||||
contact: contact.isEmpty ? nil : contact
|
||||
)
|
||||
_ = try await chemicalsClient.create(body)
|
||||
await MainActor.run { didSave = true }
|
||||
} catch {
|
||||
await MainActor.run { saveError = "Failed to save: \(error.localizedDescription)" }
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
stage = .camera
|
||||
capturedImage = nil
|
||||
capturedTexts = []
|
||||
scannedBarcode = nil
|
||||
casNumber = ""; chemicalName = ""; chemicalFormula = ""
|
||||
molecularWeight = ""; vendor = ""; catalogNumber = ""
|
||||
lotNumber = ""; concentration = ""; expirationDate = ""
|
||||
physicalState = ""; storageDevice = ""; storageLocation = ""
|
||||
numberOfContainers = "1"; amountPerContainer = ""; unitOfMeasure = ""
|
||||
percentageFull = 50; analysisError = nil; didSave = false; saveError = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main ScanView
|
||||
|
||||
struct ScanView: View {
|
||||
@State private var viewModel = ScanViewModel()
|
||||
@State private var showChemicalsList = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
switch viewModel.stage {
|
||||
case .camera:
|
||||
DataScannerWrapperView(viewModel: viewModel)
|
||||
case .analyzing:
|
||||
AnalyzingView()
|
||||
case .review:
|
||||
ReviewFormView(viewModel: viewModel, onSaved: {
|
||||
showChemicalsList = true
|
||||
})
|
||||
}
|
||||
}
|
||||
.navigationTitle(stageName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if viewModel.stage != .camera {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Rescan") { viewModel.reset() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await viewModel.loadProfile() }
|
||||
.onChange(of: viewModel.didSave) { _, saved in
|
||||
if saved { showChemicalsList = true }
|
||||
}
|
||||
}
|
||||
|
||||
private var stageName: String {
|
||||
switch viewModel.stage {
|
||||
case .camera: return "Scan Label"
|
||||
case .analyzing: return "Analysing..."
|
||||
case .review: return "Review"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Analysing placeholder
|
||||
|
||||
struct AnalyzingView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
Text("Analysing label...")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DataScanner wrapper
|
||||
|
||||
struct DataScannerWrapperView: View {
|
||||
let viewModel: ScanViewModel
|
||||
@State private var recognizedTexts: [RecognizedItem] = []
|
||||
@State private var showUnsupported = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
if DataScannerViewController.isSupported && DataScannerViewController.isAvailable {
|
||||
DataScannerRepresentable(
|
||||
recognizedItems: $recognizedTexts,
|
||||
onCapture: { image, texts, barcode in
|
||||
viewModel.capturedImage = image
|
||||
viewModel.capturedTexts = texts
|
||||
viewModel.scannedBarcode = barcode
|
||||
Task { await viewModel.analyzeTexts() }
|
||||
}
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Capture button
|
||||
VStack {
|
||||
Spacer()
|
||||
CaptureButton {
|
||||
// Capture handled inside representable via button tap callback
|
||||
}
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Scanner Unavailable",
|
||||
systemImage: "camera.slash",
|
||||
description: Text("This device does not support data scanning.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CaptureButton: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.white)
|
||||
.frame(width: 72, height: 72)
|
||||
Circle()
|
||||
.strokeBorder(.white, lineWidth: 4)
|
||||
.frame(width: 84, height: 84)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIViewControllerRepresentable for DataScanner
|
||||
|
||||
struct DataScannerRepresentable: UIViewControllerRepresentable {
|
||||
@Binding var recognizedItems: [RecognizedItem]
|
||||
let onCapture: (UIImage, [String], String?) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> DataScannerViewController {
|
||||
let scanner = DataScannerViewController(
|
||||
recognizedDataTypes: [.text(), .barcode()],
|
||||
qualityLevel: .balanced,
|
||||
recognizesMultipleItems: true,
|
||||
isHighFrameRateTrackingEnabled: true,
|
||||
isHighlightingEnabled: true
|
||||
)
|
||||
scanner.delegate = context.coordinator
|
||||
context.coordinator.scanner = scanner
|
||||
context.coordinator.onCapture = onCapture
|
||||
try? scanner.startScanning()
|
||||
return scanner
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(recognizedItems: $recognizedItems)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, DataScannerViewControllerDelegate {
|
||||
@Binding var recognizedItems: [RecognizedItem]
|
||||
weak var scanner: DataScannerViewController?
|
||||
var onCapture: ((UIImage, [String], String?) -> Void)?
|
||||
|
||||
init(recognizedItems: Binding<[RecognizedItem]>) {
|
||||
_recognizedItems = recognizedItems
|
||||
}
|
||||
|
||||
func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
|
||||
recognizedItems = allItems
|
||||
}
|
||||
|
||||
func dataScanner(_ dataScanner: DataScannerViewController, didUpdate updatedItems: [RecognizedItem], allItems: [RecognizedItem]) {
|
||||
recognizedItems = allItems
|
||||
}
|
||||
|
||||
func dataScanner(_ dataScanner: DataScannerViewController, didRemove removedItems: [RecognizedItem], allItems: [RecognizedItem]) {
|
||||
recognizedItems = allItems
|
||||
}
|
||||
|
||||
func captureCurrentFrame() {
|
||||
guard let scanner else { return }
|
||||
|
||||
// Collect all recognized text and barcodes
|
||||
var texts: [String] = []
|
||||
var barcode: String?
|
||||
|
||||
for item in recognizedItems {
|
||||
switch item {
|
||||
case .text(let t):
|
||||
texts.append(t.transcript)
|
||||
case .barcode(let b):
|
||||
if let payload = b.payloadStringValue {
|
||||
barcode = payload
|
||||
texts.append(payload)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Capture a snapshot of the current frame
|
||||
let renderer = UIGraphicsImageRenderer(bounds: scanner.view.bounds)
|
||||
let image = renderer.image { ctx in
|
||||
scanner.view.layer.render(in: ctx.cgContext)
|
||||
}
|
||||
|
||||
onCapture?(image, texts, barcode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ReviewFormView
|
||||
|
||||
struct ReviewFormView: View {
|
||||
@Bindable var viewModel: ScanViewModel
|
||||
let onSaved: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
// Captured frame + percentage slider
|
||||
if let image = viewModel.capturedImage {
|
||||
CapturedImageWithSlider(image: image, percentageFull: $viewModel.percentageFull)
|
||||
.frame(height: 260)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
if let error = viewModel.analysisError {
|
||||
Text(error)
|
||||
.foregroundStyle(.orange)
|
||||
.font(.footnote)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
Form {
|
||||
// Required fields section
|
||||
Section {
|
||||
ReviewField("Chemical Name", text: $viewModel.chemicalName, required: true, missing: viewModel.missingRequiredFields.contains("Chemical Name"))
|
||||
ReviewField("CAS Number", text: $viewModel.casNumber, required: true, missing: viewModel.missingRequiredFields.contains("CAS Number"))
|
||||
ReviewField("Physical State", text: $viewModel.physicalState)
|
||||
ReviewField("Storage Device", text: $viewModel.storageDevice)
|
||||
} header: {
|
||||
Text("Identity")
|
||||
}
|
||||
|
||||
Section {
|
||||
ReviewField("PI First Name", text: $viewModel.piFirstName, required: true, missing: viewModel.missingRequiredFields.contains("PI First Name"))
|
||||
ReviewField("Building Code", text: $viewModel.bldgCode, required: true, missing: viewModel.missingRequiredFields.contains("Building Code"))
|
||||
ReviewField("Lab", text: $viewModel.lab, required: true, missing: viewModel.missingRequiredFields.contains("Lab"))
|
||||
ReviewField("Storage Location", text: $viewModel.storageLocation, required: true, missing: viewModel.missingRequiredFields.contains("Storage Location"))
|
||||
} header: {
|
||||
Text("Location")
|
||||
}
|
||||
|
||||
Section {
|
||||
ReviewField("# Containers", text: $viewModel.numberOfContainers)
|
||||
ReviewField("Amount / Container", text: $viewModel.amountPerContainer)
|
||||
ReviewField("Unit of Measure", text: $viewModel.unitOfMeasure)
|
||||
} header: {
|
||||
Text("Quantity")
|
||||
}
|
||||
|
||||
Section {
|
||||
ReviewField("Formula", text: $viewModel.chemicalFormula)
|
||||
ReviewField("Molecular Weight", text: $viewModel.molecularWeight)
|
||||
ReviewField("Concentration", text: $viewModel.concentration)
|
||||
ReviewField("Vendor", text: $viewModel.vendor)
|
||||
ReviewField("Catalog #", text: $viewModel.catalogNumber)
|
||||
ReviewField("Lot #", text: $viewModel.lotNumber)
|
||||
ReviewField("Expiration Date", text: $viewModel.expirationDate)
|
||||
} header: {
|
||||
Text("Details")
|
||||
}
|
||||
|
||||
Section {
|
||||
if let saveError = viewModel.saveError {
|
||||
Text(saveError)
|
||||
.foregroundStyle(.red)
|
||||
.font(.footnote)
|
||||
}
|
||||
Button {
|
||||
Task { await viewModel.save() }
|
||||
} label: {
|
||||
if viewModel.isSaving {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text("Save Chemical")
|
||||
.frame(maxWidth: .infinity)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!viewModel.missingRequiredFields.isEmpty || viewModel.isSaving)
|
||||
}
|
||||
|
||||
if !viewModel.missingRequiredFields.isEmpty {
|
||||
Section {
|
||||
Text("Required: \(viewModel.missingRequiredFields.joined(separator: ", "))")
|
||||
.foregroundStyle(.red)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 800)
|
||||
.scrollDisabled(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Captured image with vertical % slider
|
||||
|
||||
struct CapturedImageWithSlider: View {
|
||||
let image: UIImage
|
||||
@Binding var percentageFull: Double
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .trailing) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
|
||||
// Dim overlay
|
||||
Color.black.opacity(0.2)
|
||||
|
||||
// Vertical slider on right edge
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
Text("\(Int(percentageFull))%")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.white)
|
||||
.shadow(radius: 2)
|
||||
|
||||
VerticalSlider(value: $percentageFull, range: 0...100)
|
||||
.frame(width: 32, height: 180)
|
||||
}
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Vertical slider
|
||||
|
||||
struct VerticalSlider: View {
|
||||
@Binding var value: Double
|
||||
let range: ClosedRange<Double>
|
||||
|
||||
@GestureState private var isDragging = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .bottom) {
|
||||
// Track
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.3))
|
||||
|
||||
// Fill
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.8))
|
||||
.frame(height: geo.size.height * fillFraction)
|
||||
|
||||
// Thumb
|
||||
Circle()
|
||||
.fill(.white)
|
||||
.frame(width: 28, height: 28)
|
||||
.shadow(radius: 3)
|
||||
.offset(y: -geo.size.height * fillFraction + 14)
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { drag in
|
||||
let fraction = 1 - (drag.location.y / geo.size.height)
|
||||
let clamped = max(range.lowerBound, min(range.upperBound, fraction * (range.upperBound - range.lowerBound) + range.lowerBound))
|
||||
value = clamped
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var fillFraction: Double {
|
||||
(value - range.lowerBound) / (range.upperBound - range.lowerBound)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ReviewField helper
|
||||
|
||||
struct ReviewField: View {
|
||||
let label: String
|
||||
@Binding var text: String
|
||||
var required: Bool = false
|
||||
var missing: Bool = false
|
||||
|
||||
init(_ label: String, text: Binding<String>, required: Bool = false, missing: Bool = false) {
|
||||
self.label = label
|
||||
self._text = text
|
||||
self.required = required
|
||||
self.missing = missing
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label + (required ? " *" : ""))
|
||||
.foregroundStyle(missing ? .red : .primary)
|
||||
.frame(minWidth: 120, alignment: .leading)
|
||||
TextField(label, text: $text)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
.listRowBackground(missing ? Color.red.opacity(0.05) : Color(uiColor: .secondarySystemGroupedBackground))
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "LabWiseKit",
|
||||
platforms: [
|
||||
.iOS(.v26)
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||
.library(
|
||||
|
||||
183
LabWiseKit/Sources/LabWiseKit/APIClient.swift
Normal file
183
LabWiseKit/Sources/LabWiseKit/APIClient.swift
Normal file
@@ -0,0 +1,183 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - API Errors
|
||||
|
||||
public enum APIError: Error, Sendable {
|
||||
case unauthorized
|
||||
case httpError(statusCode: Int, data: Data)
|
||||
case decodingError(Error)
|
||||
case encodingError(Error)
|
||||
case networkError(Error)
|
||||
case invalidURL
|
||||
}
|
||||
|
||||
// MARK: - APIClient
|
||||
|
||||
public final class APIClient: Sendable {
|
||||
public static let shared = APIClient()
|
||||
|
||||
private let baseURL = URL(string: "https://labwise.wahwa.com")!
|
||||
private let session: URLSession
|
||||
|
||||
/// Called when a 401 is received — UI layer should redirect to login.
|
||||
public nonisolated(unsafe) var onUnauthorized: (@Sendable () -> Void)?
|
||||
|
||||
private init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.httpCookieStorage = HTTPCookieStorage.shared
|
||||
config.httpShouldSetCookies = true
|
||||
config.httpCookieAcceptPolicy = .always
|
||||
session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
// MARK: - Request helpers
|
||||
|
||||
func request(
|
||||
method: String,
|
||||
path: String,
|
||||
body: (any Encodable)? = nil,
|
||||
contentType: String = "application/json"
|
||||
) async throws -> Data {
|
||||
guard let url = URL(string: path, relativeTo: baseURL) else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
|
||||
if let body {
|
||||
do {
|
||||
req.httpBody = try JSONEncoder.api.encode(body)
|
||||
} catch {
|
||||
throw APIError.encodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw APIError.networkError(URLError(.badServerResponse))
|
||||
}
|
||||
|
||||
if http.statusCode == 401 {
|
||||
// Clear cookies and signal the app
|
||||
clearSessionCookies()
|
||||
onUnauthorized?()
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw APIError.httpError(statusCode: http.statusCode, data: data)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func get<T: Decodable>(_ path: String) async throws -> T {
|
||||
let data = try await request(method: "GET", path: path)
|
||||
return try decode(T.self, from: data, decoder: .api)
|
||||
}
|
||||
|
||||
func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
||||
let data = try await request(method: "POST", path: path, body: body)
|
||||
return try decode(Response.self, from: data, decoder: .api)
|
||||
}
|
||||
|
||||
func patch<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
||||
let data = try await request(method: "PATCH", path: path, body: body)
|
||||
return try decode(Response.self, from: data, decoder: .api)
|
||||
}
|
||||
|
||||
func delete(_ path: String) async throws {
|
||||
_ = try await request(method: "DELETE", path: path)
|
||||
}
|
||||
|
||||
// MARK: - Snake_case variants (for profile endpoint)
|
||||
|
||||
func getSnakeCase<T: Decodable>(_ path: String) async throws -> T {
|
||||
let data = try await request(method: "GET", path: path)
|
||||
return try decode(T.self, from: data, decoder: .snakeCase)
|
||||
}
|
||||
|
||||
func postSnakeCase<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
||||
let encoded: Data
|
||||
do { encoded = try JSONEncoder.snakeCase.encode(body) }
|
||||
catch { throw APIError.encodingError(error) }
|
||||
let data = try await requestRaw(method: "POST", path: path, body: encoded)
|
||||
return try decode(Response.self, from: data, decoder: .snakeCase)
|
||||
}
|
||||
|
||||
// MARK: - Raw POST for auth (no decoded response needed)
|
||||
|
||||
public func postRaw(_ path: String, body: some Encodable) async throws -> Data {
|
||||
try await request(method: "POST", path: path, body: body)
|
||||
}
|
||||
|
||||
/// Low-level: sends pre-encoded body bytes.
|
||||
func requestRaw(method: String, path: String, body: Data) async throws -> Data {
|
||||
guard let url = URL(string: path, relativeTo: baseURL) else { throw APIError.invalidURL }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = body
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw APIError.networkError(URLError(.badServerResponse))
|
||||
}
|
||||
if http.statusCode == 401 { clearSessionCookies(); onUnauthorized?(); throw APIError.unauthorized }
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw APIError.httpError(statusCode: http.statusCode, data: data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func decode<T: Decodable>(_ type: T.Type, from data: Data, decoder: JSONDecoder) throws -> T {
|
||||
do {
|
||||
return try decoder.decode(type, from: data)
|
||||
} catch {
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
func clearSessionCookies() {
|
||||
let storage = HTTPCookieStorage.shared
|
||||
if let cookies = storage.cookies(for: baseURL) {
|
||||
for cookie in cookies {
|
||||
storage.deleteCookie(cookie)
|
||||
}
|
||||
}
|
||||
// Also clear the better-auth session cookie by name
|
||||
if let allCookies = storage.cookies {
|
||||
for cookie in allCookies where cookie.name == "better-auth.session_token" {
|
||||
storage.deleteCookie(cookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coder helpers
|
||||
|
||||
extension JSONEncoder {
|
||||
/// Chemicals and auth endpoints use camelCase keys as-is.
|
||||
static let api: JSONEncoder = JSONEncoder()
|
||||
|
||||
/// Profile endpoint uses snake_case keys.
|
||||
static let snakeCase: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
return encoder
|
||||
}()
|
||||
}
|
||||
|
||||
extension JSONDecoder {
|
||||
/// Chemicals and auth endpoints return camelCase keys as-is.
|
||||
static let api: JSONDecoder = JSONDecoder()
|
||||
|
||||
/// Profile endpoint returns snake_case keys.
|
||||
static let snakeCase: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
return decoder
|
||||
}()
|
||||
}
|
||||
233
LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift
Normal file
233
LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift
Normal file
@@ -0,0 +1,233 @@
|
||||
import Foundation
|
||||
import AuthenticationServices
|
||||
|
||||
public struct SignInBody: Encodable, Sendable {
|
||||
public let email: String
|
||||
public let password: String
|
||||
|
||||
public init(email: String, password: String) {
|
||||
self.email = email
|
||||
self.password = password
|
||||
}
|
||||
}
|
||||
|
||||
/// Better Auth wraps the sign-in response as { data: { user, session } }
|
||||
public struct SignInResponse: Decodable, Sendable {
|
||||
public let data: SignInData?
|
||||
public let error: SignInError?
|
||||
|
||||
public struct SignInData: Decodable, Sendable {
|
||||
public let user: AuthUser
|
||||
public let session: AuthSession
|
||||
}
|
||||
|
||||
public struct SignInError: Decodable, Sendable {
|
||||
public let message: String?
|
||||
}
|
||||
}
|
||||
|
||||
public struct AuthSession: Decodable, Sendable {
|
||||
public let id: String
|
||||
public let token: String
|
||||
public let userId: String
|
||||
public let expiresAt: String
|
||||
}
|
||||
|
||||
public struct AuthUser: Decodable, Sendable {
|
||||
public let id: String
|
||||
public let email: String
|
||||
public let name: String?
|
||||
public let emailVerified: Bool?
|
||||
public let image: String?
|
||||
}
|
||||
|
||||
/// Thin wrapper around the /api/auth/session endpoint response.
|
||||
private struct SessionResponse: Decodable, Sendable {
|
||||
let user: AuthUser?
|
||||
let session: AuthSession?
|
||||
}
|
||||
|
||||
public final class AuthClient: Sendable {
|
||||
private let api: APIClient
|
||||
private let baseURL = "https://labwise.wahwa.com"
|
||||
|
||||
public init(api: APIClient = .shared) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
// MARK: - Email / Password
|
||||
|
||||
/// Sign in with email and password. Returns the authenticated user on success.
|
||||
public func signIn(email: String, password: String) async throws -> AuthUser {
|
||||
let body = SignInBody(email: email, password: password)
|
||||
let encoded = try JSONEncoder().encode(body)
|
||||
var req = URLRequest(url: URL(string: "\(baseURL)/api/auth/sign-in/email")!)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = encoded
|
||||
|
||||
let session = sharedURLSession()
|
||||
let (data, response) = try await session.data(for: req)
|
||||
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw APIError.networkError(URLError(.badServerResponse))
|
||||
}
|
||||
|
||||
// Better Auth may return 200 with an error body, or a non-200 status
|
||||
let signInResponse = try JSONDecoder.api.decode(SignInResponse.self, from: data)
|
||||
if let errMsg = signInResponse.error?.message {
|
||||
throw APIError.httpError(statusCode: http.statusCode, data: Data(errMsg.utf8))
|
||||
}
|
||||
guard http.statusCode == 200 else {
|
||||
throw APIError.httpError(statusCode: http.statusCode, data: data)
|
||||
}
|
||||
guard let user = signInResponse.data?.user else {
|
||||
throw APIError.decodingError(DecodingError.dataCorrupted(
|
||||
.init(codingPath: [], debugDescription: "Missing user in sign-in response")
|
||||
))
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// MARK: - Google OAuth
|
||||
|
||||
/// Initiates Google OAuth via ASWebAuthenticationSession.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Opens /api/auth/sign-in/google with callbackURL pointing at /api/ios-callback (HTTPS).
|
||||
/// 2. ASWebAuthenticationSession follows all redirects through Google and back to the server.
|
||||
/// 3. Better Auth sets the session cookie on labwise.wahwa.com, then redirects to /api/ios-callback.
|
||||
/// 4. /api/ios-callback reads the cookie, extracts the raw token, and redirects to labwise://auth?token=…
|
||||
/// 5. ASWebAuthenticationSession intercepts the labwise:// scheme and delivers the URL to us.
|
||||
/// 6. We parse the token, inject it as a cookie into HTTPCookieStorage.shared, then fetch the user.
|
||||
///
|
||||
/// This avoids the cookie-jar isolation problem: ASWebAuthenticationSession uses the Safari jar
|
||||
/// while URLSession uses HTTPCookieStorage.shared. By extracting the token from the URL we can
|
||||
/// bridge between the two.
|
||||
@MainActor
|
||||
public func signInWithGoogle(presentingWindow: ASPresentationAnchor) async throws -> AuthUser {
|
||||
let callbackScheme = "labwise"
|
||||
// callbackURL is a real HTTPS endpoint on our server — not the custom scheme.
|
||||
// Better Auth will redirect there after a successful Google sign-in.
|
||||
let iosCallbackURL = "\(baseURL)/api/ios-callback"
|
||||
|
||||
// /api/ios-google is a GET endpoint on our server that internally calls
|
||||
// Better Auth's POST /sign-in/social, forwards the state cookie it receives,
|
||||
// then redirects to Google. Because the whole flow happens within
|
||||
// ASWebAuthenticationSession's Safari jar, the state cookie is present when
|
||||
// Google redirects back — no state_mismatch.
|
||||
guard
|
||||
let encodedCallback = iosCallbackURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let authURL = URL(string: "\(baseURL)/api/ios-google?callbackURL=\(encodedCallback)")
|
||||
else { throw APIError.invalidURL }
|
||||
|
||||
let anchor = WindowAnchorProvider(window: presentingWindow)
|
||||
|
||||
// Keep a strong reference to the session outside the continuation closure
|
||||
// so it isn't deallocated before the callback fires.
|
||||
var webSession: ASWebAuthenticationSession?
|
||||
let callbackURL: URL = try await withCheckedThrowingContinuation { continuation in
|
||||
let session = ASWebAuthenticationSession(
|
||||
url: authURL,
|
||||
callbackURLScheme: callbackScheme
|
||||
) { url, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: APIError.networkError(error))
|
||||
} else if let url {
|
||||
continuation.resume(returning: url)
|
||||
} else {
|
||||
continuation.resume(throwing: APIError.unauthorized)
|
||||
}
|
||||
}
|
||||
session.presentationContextProvider = anchor
|
||||
// Non-ephemeral so the in-browser Google account chooser can remember the user,
|
||||
// but the token is delivered via URL so the cookie jar difference doesn't matter.
|
||||
session.prefersEphemeralWebBrowserSession = false
|
||||
webSession = session
|
||||
session.start()
|
||||
}
|
||||
_ = webSession // keep alive until continuation resolves
|
||||
|
||||
// Parse labwise://auth?token=<value>&error=<value>
|
||||
guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else {
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
if let errorParam = components.queryItems?.first(where: { $0.name == "error" })?.value {
|
||||
throw APIError.httpError(statusCode: 401, data: Data(errorParam.utf8))
|
||||
}
|
||||
guard let rawToken = components.queryItems?.first(where: { $0.name == "token" })?.value,
|
||||
!rawToken.isEmpty else {
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
|
||||
// URLComponents percent-decodes once; if the server double-encoded the token
|
||||
// (encodeURIComponent on an already-encoded value) decode any remaining %XX sequences.
|
||||
let token = rawToken.removingPercentEncoding ?? rawToken
|
||||
injectSessionCookie(token: token)
|
||||
|
||||
return try await fetchCurrentUser()
|
||||
}
|
||||
|
||||
// MARK: - Cookie injection
|
||||
|
||||
private func injectSessionCookie(token: String) {
|
||||
guard let serverURL = URL(string: baseURL) else { return }
|
||||
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.
|
||||
]
|
||||
if let cookie = HTTPCookie(properties: properties) {
|
||||
HTTPCookieStorage.shared.setCookie(cookie)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session
|
||||
|
||||
/// Fetch the currently authenticated user from the session endpoint.
|
||||
public func fetchCurrentUser() async throws -> AuthUser {
|
||||
var req = URLRequest(url: URL(string: "\(baseURL)/api/auth/get-session")!)
|
||||
req.httpMethod = "GET"
|
||||
let session = sharedURLSession()
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
let sessionResponse = try JSONDecoder.api.decode(SessionResponse.self, from: data)
|
||||
guard let user = sessionResponse.user else {
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// MARK: - Sign Out
|
||||
|
||||
/// Sign out. Clears the session cookie.
|
||||
public func signOut() async throws {
|
||||
_ = try? await api.postRaw("/api/auth/sign-out", body: EmptyBody())
|
||||
api.clearSessionCookies()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func sharedURLSession() -> URLSession {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.httpCookieStorage = HTTPCookieStorage.shared
|
||||
config.httpShouldSetCookies = true
|
||||
config.httpCookieAcceptPolicy = .always
|
||||
return URLSession(configuration: config)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ASWebAuthenticationPresentationContextProviding
|
||||
|
||||
private final class WindowAnchorProvider: NSObject, ASWebAuthenticationPresentationContextProviding, @unchecked Sendable {
|
||||
let window: ASPresentationAnchor
|
||||
init(window: ASPresentationAnchor) { self.window = window }
|
||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { window }
|
||||
}
|
||||
|
||||
private struct EmptyBody: Encodable {}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
public final class ChemicalsClient: Sendable {
|
||||
private let api: APIClient
|
||||
|
||||
public init(api: APIClient = .shared) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
public func list() async throws -> [Chemical] {
|
||||
try await api.get("/api/chemicals")
|
||||
}
|
||||
|
||||
public func create(_ body: ChemicalCreateBody) async throws -> Chemical {
|
||||
try await api.post("/api/chemicals", body: body)
|
||||
}
|
||||
|
||||
public func update(id: String, body: ChemicalCreateBody) async throws -> Chemical {
|
||||
try await api.patch("/api/chemicals/\(id)", body: body)
|
||||
}
|
||||
|
||||
public func delete(id: String) async throws {
|
||||
try await api.delete("/api/chemicals/\(id)")
|
||||
}
|
||||
}
|
||||
17
LabWiseKit/Sources/LabWiseKit/Endpoints/ProfileClient.swift
Normal file
17
LabWiseKit/Sources/LabWiseKit/Endpoints/ProfileClient.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
public final class ProfileClient: Sendable {
|
||||
private let api: APIClient
|
||||
|
||||
public init(api: APIClient = .shared) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
public func get() async throws -> UserProfile {
|
||||
try await api.get("/api/profile")
|
||||
}
|
||||
|
||||
public func upsert(_ body: UserProfileUpsertBody) async throws -> UserProfile {
|
||||
try await api.post("/api/profile", body: body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
public final class ProtocolsClient: Sendable {
|
||||
private let api: APIClient
|
||||
|
||||
public init(api: APIClient = .shared) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
public func list() async throws -> [LabProtocol] {
|
||||
try await api.get("/api/protocols")
|
||||
}
|
||||
|
||||
public func delete(id: String) async throws {
|
||||
try await api.delete("/api/protocols/\(id)")
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
// The Swift Programming Language
|
||||
// https://docs.swift.org/swift-book
|
||||
// LabWiseKit — public re-exports
|
||||
// All public types are defined in their respective files.
|
||||
// This file intentionally left minimal.
|
||||
|
||||
@@ -2,7 +2,6 @@ import Foundation
|
||||
|
||||
public struct Chemical: Codable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
// Required
|
||||
public var piFirstName: String
|
||||
public var physicalState: String
|
||||
public var chemicalName: String
|
||||
@@ -14,7 +13,6 @@ public struct Chemical: Codable, Identifiable, Sendable {
|
||||
public var amountPerContainer: String
|
||||
public var unitOfMeasure: String
|
||||
public var casNumber: String
|
||||
// Optional
|
||||
public var chemicalFormula: String?
|
||||
public var molecularWeight: String?
|
||||
public var vendor: String?
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
/// A lab protocol document. Named `LabProtocol` to avoid collision with the Swift keyword `Protocol`.
|
||||
/// The server returns snake_case keys for this model.
|
||||
public struct LabProtocol: Codable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var userId: String?
|
||||
public var title: String
|
||||
public var content: String
|
||||
public var fileUrl: String?
|
||||
@@ -11,11 +13,19 @@ public struct LabProtocol: Codable, Identifiable, Sendable {
|
||||
public var updatedAt: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title, content, fileUrl, analysisResults, createdAt, updatedAt
|
||||
case id
|
||||
case userId = "user_id"
|
||||
case title
|
||||
case content
|
||||
case fileUrl = "file_url"
|
||||
case analysisResults = "analysis_results"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
public init(
|
||||
id: String = "",
|
||||
userId: String? = nil,
|
||||
title: String = "",
|
||||
content: String = "",
|
||||
fileUrl: String? = nil,
|
||||
@@ -24,6 +34,7 @@ public struct LabProtocol: Codable, Identifiable, Sendable {
|
||||
updatedAt: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.userId = userId
|
||||
self.title = title
|
||||
self.content = content
|
||||
self.fileUrl = fileUrl
|
||||
@@ -35,10 +46,10 @@ public struct LabProtocol: Codable, Identifiable, Sendable {
|
||||
|
||||
// MARK: - AnyCodable helper
|
||||
|
||||
public struct AnyCodable: Codable, Sendable {
|
||||
public let value: any Sendable
|
||||
public struct AnyCodable: Codable, @unchecked Sendable {
|
||||
public let value: Any
|
||||
|
||||
public init(_ value: any Sendable) {
|
||||
public init(_ value: Any) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
|
||||
54
LabWiseKit/Sources/LabWiseKit/Models/UserProfile.swift
Normal file
54
LabWiseKit/Sources/LabWiseKit/Models/UserProfile.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
/// Profile data returned by the server uses snake_case keys.
|
||||
public struct UserProfile: Codable, Sendable {
|
||||
public var userId: String
|
||||
public var piFirstName: String
|
||||
public var bldgCode: String
|
||||
public var lab: String
|
||||
public var contact: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userId = "user_id"
|
||||
case piFirstName = "pi_first_name"
|
||||
case bldgCode = "bldg_code"
|
||||
case lab
|
||||
case contact
|
||||
}
|
||||
|
||||
public init(
|
||||
userId: String = "",
|
||||
piFirstName: String = "",
|
||||
bldgCode: String = "",
|
||||
lab: String = "",
|
||||
contact: String? = nil
|
||||
) {
|
||||
self.userId = userId
|
||||
self.piFirstName = piFirstName
|
||||
self.bldgCode = bldgCode
|
||||
self.lab = lab
|
||||
self.contact = contact
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body for POST /api/profile — server expects snake_case keys.
|
||||
public struct UserProfileUpsertBody: Codable, Sendable {
|
||||
public var piFirstName: String
|
||||
public var bldgCode: String
|
||||
public var lab: String
|
||||
public var contact: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case piFirstName = "pi_first_name"
|
||||
case bldgCode = "bldg_code"
|
||||
case lab
|
||||
case contact
|
||||
}
|
||||
|
||||
public init(piFirstName: String, bldgCode: String, lab: String, contact: String? = nil) {
|
||||
self.piFirstName = piFirstName
|
||||
self.bldgCode = bldgCode
|
||||
self.lab = lab
|
||||
self.contact = contact
|
||||
}
|
||||
}
|
||||
8
LabWiseUITests/LabWiseKit/.gitignore
vendored
8
LabWiseUITests/LabWiseKit/.gitignore
vendored
@@ -1,8 +0,0 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
@@ -1,26 +0,0 @@
|
||||
// swift-tools-version: 6.2
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "LabWiseKit",
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||
.library(
|
||||
name: "LabWiseKit",
|
||||
targets: ["LabWiseKit"]
|
||||
),
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||
// Targets can depend on other targets in this package and products from dependencies.
|
||||
.target(
|
||||
name: "LabWiseKit"
|
||||
),
|
||||
.testTarget(
|
||||
name: "LabWiseKitTests",
|
||||
dependencies: ["LabWiseKit"]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -1,2 +0,0 @@
|
||||
// The Swift Programming Language
|
||||
// https://docs.swift.org/swift-book
|
||||
@@ -1,6 +0,0 @@
|
||||
import Testing
|
||||
@testable import LabWiseKit
|
||||
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
}
|
||||
Reference in New Issue
Block a user