From 2110c13ea1c1a64496803d5c39adc6eeaaaf31fa Mon Sep 17 00:00:00 2001 From: pulipakaa24 Date: Fri, 20 Mar 2026 02:06:19 -0500 Subject: [PATCH] More UI Changes? Not sure why they didn't go previously --- LabWise.xcodeproj/project.pbxproj | 48 ++ LabWise/AppState.swift | 58 ++ LabWise/ChemicalDetailView.swift | 81 +++ LabWise/ChemicalsListView.swift | 147 +++++ LabWise/DashboardView.swift | 2 +- LabWise/Info.plist | 19 + LabWise/LabWiseApp.swift | 18 +- LabWise/LoginView.swift | 32 +- LabWise/ProfileView.swift | 144 +++++ LabWise/ScanView.swift | 609 ++++++++++++++++++ LabWiseKit/Package.swift | 3 + LabWiseKit/Sources/LabWiseKit/APIClient.swift | 183 ++++++ .../Sources/LabWiseKit/Auth/AuthClient.swift | 233 +++++++ .../Endpoints/ChemicalsClient.swift | 25 + .../LabWiseKit/Endpoints/ProfileClient.swift | 17 + .../Endpoints/ProtocolsClient.swift | 17 + .../Sources/LabWiseKit/LabWiseKit.swift | 5 +- .../Sources/LabWiseKit/Models/Chemical.swift | 2 - .../LabWiseKit/Models/LabProtocol.swift | 19 +- .../LabWiseKit/Models/UserProfile.swift | 54 ++ LabWiseUITests/LabWiseKit/.gitignore | 8 - LabWiseUITests/LabWiseKit/Package.swift | 26 - .../Sources/LabWiseKit/LabWiseKit.swift | 2 - .../LabWiseKitTests/LabWiseKitTests.swift | 6 - 24 files changed, 1688 insertions(+), 70 deletions(-) create mode 100644 LabWise/AppState.swift create mode 100644 LabWise/ChemicalDetailView.swift create mode 100644 LabWise/ChemicalsListView.swift create mode 100644 LabWise/Info.plist create mode 100644 LabWise/ProfileView.swift create mode 100644 LabWise/ScanView.swift create mode 100644 LabWiseKit/Sources/LabWiseKit/APIClient.swift create mode 100644 LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift create mode 100644 LabWiseKit/Sources/LabWiseKit/Endpoints/ChemicalsClient.swift create mode 100644 LabWiseKit/Sources/LabWiseKit/Endpoints/ProfileClient.swift create mode 100644 LabWiseKit/Sources/LabWiseKit/Endpoints/ProtocolsClient.swift create mode 100644 LabWiseKit/Sources/LabWiseKit/Models/UserProfile.swift delete mode 100644 LabWiseUITests/LabWiseKit/.gitignore delete mode 100644 LabWiseUITests/LabWiseKit/Package.swift delete mode 100644 LabWiseUITests/LabWiseKit/Sources/LabWiseKit/LabWiseKit.swift delete mode 100644 LabWiseUITests/LabWiseKit/Tests/LabWiseKitTests/LabWiseKitTests.swift diff --git a/LabWise.xcodeproj/project.pbxproj b/LabWise.xcodeproj/project.pbxproj index ca868d2..54e230d 100644 --- a/LabWise.xcodeproj/project.pbxproj +++ b/LabWise.xcodeproj/project.pbxproj @@ -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 = ""; }; @@ -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 = ""; + }; 86FE2C032F6CDAF000FAC3D8 = { isa = PBXGroup; children = ( @@ -79,6 +105,8 @@ 86FE2C1C2F6CDAF200FAC3D8 /* LabWiseTests */, 86FE2C262F6CDAF200FAC3D8 /* LabWiseUITests */, 86FE2C0D2F6CDAF000FAC3D8 /* Products */, + 868B57E42F6D017F00729F36 /* LabWiseKit */, + 869A22C82F6D022900C58926 /* Frameworks */, ); sourceTree = ""; }; @@ -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 */; } diff --git a/LabWise/AppState.swift b/LabWise/AppState.swift new file mode 100644 index 0000000..2d79b6a --- /dev/null +++ b/LabWise/AppState.swift @@ -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 + } +} diff --git a/LabWise/ChemicalDetailView.swift b/LabWise/ChemicalDetailView.swift new file mode 100644 index 0000000..9284c06 --- /dev/null +++ b/LabWise/ChemicalDetailView.swift @@ -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) + } +} diff --git a/LabWise/ChemicalsListView.swift b/LabWise/ChemicalsListView.swift new file mode 100644 index 0000000..fba85b7 --- /dev/null +++ b/LabWise/ChemicalsListView.swift @@ -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 + } +} diff --git a/LabWise/DashboardView.swift b/LabWise/DashboardView.swift index 11641bd..fcd1015 100644 --- a/LabWise/DashboardView.swift +++ b/LabWise/DashboardView.swift @@ -13,7 +13,7 @@ struct DashboardView: View { ProfileView() } } - .tint(Color("Brand/BrandPrimary")) + .tint(Color(.brandPrimary)) } } diff --git a/LabWise/Info.plist b/LabWise/Info.plist new file mode 100644 index 0000000..f6145d1 --- /dev/null +++ b/LabWise/Info.plist @@ -0,0 +1,19 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.adipu.labwise + CFBundleURLSchemes + + labwise + + + + + diff --git a/LabWise/LabWiseApp.swift b/LabWise/LabWiseApp.swift index 96dfe0d..c109b95 100644 --- a/LabWise/LabWiseApp.swift +++ b/LabWise/LabWiseApp.swift @@ -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) + } } } } diff --git a/LabWise/LoginView.swift b/LabWise/LoginView.swift index b800b36..a88cbc0 100644 --- a/LabWise/LoginView.swift +++ b/LabWise/LoginView.swift @@ -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) diff --git a/LabWise/ProfileView.swift b/LabWise/ProfileView.swift new file mode 100644 index 0000000..292e2e6 --- /dev/null +++ b/LabWise/ProfileView.swift @@ -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() + } + } +} diff --git a/LabWise/ScanView.swift b/LabWise/ScanView.swift new file mode 100644 index 0000000..61d43d9 --- /dev/null +++ b/LabWise/ScanView.swift @@ -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 + + @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, 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)) + } +} diff --git a/LabWiseKit/Package.swift b/LabWiseKit/Package.swift index 4b7928f..4feea00 100644 --- a/LabWiseKit/Package.swift +++ b/LabWiseKit/Package.swift @@ -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( diff --git a/LabWiseKit/Sources/LabWiseKit/APIClient.swift b/LabWiseKit/Sources/LabWiseKit/APIClient.swift new file mode 100644 index 0000000..7c131cf --- /dev/null +++ b/LabWiseKit/Sources/LabWiseKit/APIClient.swift @@ -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(_ 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(_ 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(_ 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(_ 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(_ 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(_ 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 + }() +} diff --git a/LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift b/LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift new file mode 100644 index 0000000..25ba89c --- /dev/null +++ b/LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift @@ -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=&error= + 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 {} diff --git a/LabWiseKit/Sources/LabWiseKit/Endpoints/ChemicalsClient.swift b/LabWiseKit/Sources/LabWiseKit/Endpoints/ChemicalsClient.swift new file mode 100644 index 0000000..30187a4 --- /dev/null +++ b/LabWiseKit/Sources/LabWiseKit/Endpoints/ChemicalsClient.swift @@ -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)") + } +} diff --git a/LabWiseKit/Sources/LabWiseKit/Endpoints/ProfileClient.swift b/LabWiseKit/Sources/LabWiseKit/Endpoints/ProfileClient.swift new file mode 100644 index 0000000..75cc994 --- /dev/null +++ b/LabWiseKit/Sources/LabWiseKit/Endpoints/ProfileClient.swift @@ -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) + } +} diff --git a/LabWiseKit/Sources/LabWiseKit/Endpoints/ProtocolsClient.swift b/LabWiseKit/Sources/LabWiseKit/Endpoints/ProtocolsClient.swift new file mode 100644 index 0000000..6412034 --- /dev/null +++ b/LabWiseKit/Sources/LabWiseKit/Endpoints/ProtocolsClient.swift @@ -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)") + } +} diff --git a/LabWiseKit/Sources/LabWiseKit/LabWiseKit.swift b/LabWiseKit/Sources/LabWiseKit/LabWiseKit.swift index 08b22b8..f56bcd4 100644 --- a/LabWiseKit/Sources/LabWiseKit/LabWiseKit.swift +++ b/LabWiseKit/Sources/LabWiseKit/LabWiseKit.swift @@ -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. diff --git a/LabWiseKit/Sources/LabWiseKit/Models/Chemical.swift b/LabWiseKit/Sources/LabWiseKit/Models/Chemical.swift index f239168..8b9562b 100644 --- a/LabWiseKit/Sources/LabWiseKit/Models/Chemical.swift +++ b/LabWiseKit/Sources/LabWiseKit/Models/Chemical.swift @@ -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? diff --git a/LabWiseKit/Sources/LabWiseKit/Models/LabProtocol.swift b/LabWiseKit/Sources/LabWiseKit/Models/LabProtocol.swift index fb166e2..b32d564 100644 --- a/LabWiseKit/Sources/LabWiseKit/Models/LabProtocol.swift +++ b/LabWiseKit/Sources/LabWiseKit/Models/LabProtocol.swift @@ -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 } diff --git a/LabWiseKit/Sources/LabWiseKit/Models/UserProfile.swift b/LabWiseKit/Sources/LabWiseKit/Models/UserProfile.swift new file mode 100644 index 0000000..9f52411 --- /dev/null +++ b/LabWiseKit/Sources/LabWiseKit/Models/UserProfile.swift @@ -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 + } +} diff --git a/LabWiseUITests/LabWiseKit/.gitignore b/LabWiseUITests/LabWiseKit/.gitignore deleted file mode 100644 index 0023a53..0000000 --- a/LabWiseUITests/LabWiseKit/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/LabWiseUITests/LabWiseKit/Package.swift b/LabWiseUITests/LabWiseKit/Package.swift deleted file mode 100644 index 4b7928f..0000000 --- a/LabWiseUITests/LabWiseKit/Package.swift +++ /dev/null @@ -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"] - ), - ] -) diff --git a/LabWiseUITests/LabWiseKit/Sources/LabWiseKit/LabWiseKit.swift b/LabWiseUITests/LabWiseKit/Sources/LabWiseKit/LabWiseKit.swift deleted file mode 100644 index 08b22b8..0000000 --- a/LabWiseUITests/LabWiseKit/Sources/LabWiseKit/LabWiseKit.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/LabWiseUITests/LabWiseKit/Tests/LabWiseKitTests/LabWiseKitTests.swift b/LabWiseUITests/LabWiseKit/Tests/LabWiseKitTests/LabWiseKitTests.swift deleted file mode 100644 index 3e960a2..0000000 --- a/LabWiseUITests/LabWiseKit/Tests/LabWiseKitTests/LabWiseKitTests.swift +++ /dev/null @@ -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. -}