211 lines
7.9 KiB
Swift
211 lines
7.9 KiB
Swift
import SwiftUI
|
|
import AuthenticationServices
|
|
import LabWiseKit
|
|
|
|
struct LoginView: View {
|
|
@Environment(AppState.self) private var appState
|
|
|
|
@State private var email = ""
|
|
@State private var password = ""
|
|
@State private var isLoading = false
|
|
@State private var isGoogleLoading = false
|
|
@State private var errorMessage: String?
|
|
|
|
private let authClient = AuthClient()
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
Color(UIColor.systemGroupedBackground)
|
|
.ignoresSafeArea()
|
|
|
|
VStack(spacing: 32) {
|
|
Spacer()
|
|
|
|
// Logo
|
|
VStack(spacing: 16) {
|
|
Image("Logo")
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(height: 52)
|
|
Text("Chemical Inventory Management")
|
|
.font(.subheadline)
|
|
.foregroundStyle(Color(.brandMutedForeground))
|
|
}
|
|
|
|
// Card
|
|
VStack(spacing: 20) {
|
|
VStack(spacing: 12) {
|
|
TextField("Email", text: $email)
|
|
.keyboardType(.emailAddress)
|
|
.textContentType(.emailAddress)
|
|
.autocapitalization(.none)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.background(Color(UIColor.secondarySystemGroupedBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.strokeBorder(Color(.brandPrimary).opacity(0.2), lineWidth: 1)
|
|
)
|
|
|
|
SecureField("Password", text: $password)
|
|
.textContentType(.password)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.background(Color(UIColor.secondarySystemGroupedBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.strokeBorder(Color(.brandPrimary).opacity(0.2), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
if let errorMessage {
|
|
Text(errorMessage)
|
|
.foregroundStyle(.red)
|
|
.font(.footnote)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
|
|
Button {
|
|
Task { await signIn() }
|
|
} label: {
|
|
Group {
|
|
if isLoading {
|
|
ProgressView()
|
|
.tint(.white)
|
|
} else {
|
|
Text("Sign In")
|
|
.fontWeight(.semibold)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 44)
|
|
}
|
|
.background(Color(.brandPrimary))
|
|
.foregroundStyle(.white)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
.disabled(email.isEmpty || password.isEmpty || isLoading || isGoogleLoading)
|
|
.opacity((email.isEmpty || password.isEmpty || isLoading || isGoogleLoading) ? 0.5 : 1)
|
|
|
|
HStack {
|
|
Rectangle()
|
|
.fill(Color(.brandPrimary).opacity(0.15))
|
|
.frame(height: 1)
|
|
Text("or")
|
|
.font(.footnote)
|
|
.foregroundStyle(Color(.brandMutedForeground))
|
|
Rectangle()
|
|
.fill(Color(.brandPrimary).opacity(0.15))
|
|
.frame(height: 1)
|
|
}
|
|
|
|
GoogleSignInButton(isLoading: isGoogleLoading) {
|
|
Task { await signInWithGoogle() }
|
|
}
|
|
.disabled(isLoading || isGoogleLoading)
|
|
}
|
|
.padding(24)
|
|
.background(Color(UIColor.secondarySystemGroupedBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
.shadow(color: Color(.brandPrimary).opacity(0.06), radius: 12, x: 0, y: 4)
|
|
.padding(.horizontal, 24)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
.navigationBarHidden(true)
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func signIn() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
defer { isLoading = false }
|
|
|
|
do {
|
|
let user = try await authClient.signIn(email: email, password: password)
|
|
await MainActor.run { appState.signedIn(user: user) }
|
|
} catch APIError.httpError(let code, _) where code == 401 {
|
|
await MainActor.run { errorMessage = "Invalid email or password." }
|
|
} catch APIError.httpError(_, let data) {
|
|
let msg = String(data: data, encoding: .utf8) ?? "Sign in failed."
|
|
await MainActor.run { errorMessage = msg }
|
|
} catch {
|
|
await MainActor.run { errorMessage = "Sign in failed. Please check your connection." }
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func signInWithGoogle() async {
|
|
guard let window = UIApplication.shared.connectedScenes
|
|
.compactMap({ $0 as? UIWindowScene })
|
|
.flatMap({ $0.windows })
|
|
.first(where: { $0.isKeyWindow }) else { return }
|
|
|
|
isGoogleLoading = true
|
|
errorMessage = nil
|
|
defer { isGoogleLoading = false }
|
|
|
|
do {
|
|
let user = try await authClient.signInWithGoogle(presentingWindow: window)
|
|
appState.signedIn(user: user)
|
|
} catch ASWebAuthenticationSessionError.canceledLogin {
|
|
// User cancelled — no error shown
|
|
} catch {
|
|
errorMessage = "Google sign-in failed. Please try again."
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Google button
|
|
|
|
struct GoogleSignInButton: View {
|
|
let isLoading: Bool
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 10) {
|
|
if isLoading {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity)
|
|
} else {
|
|
GoogleGLogo()
|
|
.frame(width: 18, height: 18)
|
|
Text("Continue with Google")
|
|
.fontWeight(.medium)
|
|
.foregroundStyle(Color(UIColor.label))
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 44)
|
|
.background(Color(UIColor.systemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.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)
|
|
}
|