More UI Changes? Not sure why they didn't go previously

This commit is contained in:
2026-03-20 02:06:19 -05:00
parent efec14139c
commit 2110c13ea1
24 changed files with 1688 additions and 70 deletions

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

View 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 {}

View File

@@ -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)")
}
}

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

View File

@@ -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)")
}
}

View File

@@ -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.

View File

@@ -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?

View File

@@ -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
}

View 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
}
}