All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
387 lines
13 KiB
Swift
387 lines
13 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - AuthView
|
||
// Full-screen login / register view.
|
||
// Mirrors the web UI's login page: zinc-900 background, tab switcher with
|
||
// amber underline indicator, zinc-800 text fields with amber focus ring,
|
||
// amber CTA button, inline error banner, loading state.
|
||
|
||
struct AuthView: View {
|
||
@EnvironmentObject var authStore: AuthStore
|
||
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||
|
||
@State private var mode: AuthMode = .login
|
||
|
||
// Login fields
|
||
@State private var loginUsername: String = ""
|
||
@State private var loginPassword: String = ""
|
||
|
||
// Register fields
|
||
@State private var regUsername: String = ""
|
||
@State private var regPassword: String = ""
|
||
@State private var regConfirm: String = ""
|
||
|
||
// Focus management
|
||
@FocusState private var focus: AuthField?
|
||
|
||
// Local validation error (client-side, e.g. password mismatch)
|
||
@State private var localError: String?
|
||
|
||
private var displayError: String? { localError ?? authStore.error }
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Color.appBackground.ignoresSafeArea()
|
||
|
||
ScrollView {
|
||
VStack(spacing: 0) {
|
||
Spacer(minLength: 60)
|
||
|
||
// ── Wordmark ──────────────────────────────────────────
|
||
wordmark
|
||
|
||
Spacer(minLength: 48)
|
||
|
||
// ── Card ──────────────────────────────────────────────
|
||
VStack(spacing: 0) {
|
||
tabSwitcher
|
||
formContent
|
||
}
|
||
.background(Color.cardBackground)
|
||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||
.padding(.horizontal, 24)
|
||
|
||
Spacer(minLength: 40)
|
||
}
|
||
}
|
||
.scrollDismissesKeyboard(.interactively)
|
||
}
|
||
.onChange(of: mode) { _, _ in
|
||
localError = nil
|
||
authStore.error = nil
|
||
}
|
||
}
|
||
|
||
// MARK: - Wordmark
|
||
|
||
private var wordmark: some View {
|
||
VStack(spacing: 6) {
|
||
Image(systemName: "books.vertical.fill")
|
||
.font(.system(size: 44))
|
||
.foregroundStyle(Color.amber)
|
||
.symbolEffect(.bounce, value: mode)
|
||
|
||
Text("libnovel")
|
||
.font(.title.bold())
|
||
.fontDesign(.serif)
|
||
.foregroundStyle(.primary)
|
||
}
|
||
}
|
||
|
||
// MARK: - Tab switcher
|
||
|
||
private var tabSwitcher: some View {
|
||
HStack(spacing: 0) {
|
||
tabButton(label: "Sign in", tab: .login)
|
||
tabButton(label: "Create account", tab: .register)
|
||
}
|
||
.overlay(alignment: .bottom) {
|
||
Rectangle()
|
||
.fill(Color.cardBorder)
|
||
.frame(height: 1)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func tabButton(label: String, tab: AuthMode) -> some View {
|
||
let isActive = mode == tab
|
||
Button {
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||
mode = tab
|
||
}
|
||
} label: {
|
||
VStack(spacing: 0) {
|
||
Text(label)
|
||
.font(.subheadline.weight(.medium))
|
||
.foregroundStyle(isActive ? Color.amber : Color.secondary)
|
||
.padding(.vertical, 14)
|
||
.frame(maxWidth: .infinity)
|
||
|
||
// Active underline indicator
|
||
Rectangle()
|
||
.fill(isActive ? Color.amber : Color.clear)
|
||
.frame(height: 2)
|
||
.offset(y: 1) // sits on top of the border
|
||
}
|
||
}
|
||
.accessibilityAddTraits(isActive ? [.isSelected] : [])
|
||
}
|
||
|
||
// MARK: - Form content
|
||
|
||
@ViewBuilder
|
||
private var formContent: some View {
|
||
VStack(spacing: 16) {
|
||
// Error banner
|
||
if let err = displayError {
|
||
errorBanner(err)
|
||
.transition(.move(edge: .top).combined(with: .opacity))
|
||
}
|
||
|
||
switch mode {
|
||
case .login: loginForm
|
||
case .register: registerForm
|
||
}
|
||
}
|
||
.padding(20)
|
||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: displayError)
|
||
.animation(.spring(response: 0.35, dampingFraction: 0.75), value: mode)
|
||
}
|
||
|
||
// MARK: - Error banner
|
||
|
||
private func errorBanner(_ message: String) -> some View {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.foregroundStyle(Color.errorText)
|
||
.font(.footnote)
|
||
Text(message)
|
||
.font(.footnote)
|
||
.foregroundStyle(Color.errorText)
|
||
.multilineTextAlignment(.leading)
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 10)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(Color.errorBackground)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||
.stroke(Color.errorBorder, lineWidth: 1)
|
||
)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||
}
|
||
|
||
// MARK: - Login form
|
||
|
||
private var loginForm: some View {
|
||
VStack(spacing: 16) {
|
||
AuthInputField(
|
||
label: "Username",
|
||
placeholder: "your_username",
|
||
text: $loginUsername,
|
||
contentType: .username,
|
||
keyboardType: .default,
|
||
focusState: $focus,
|
||
field: .loginUsername,
|
||
nextField: .loginPassword
|
||
)
|
||
|
||
AuthInputField(
|
||
label: "Password",
|
||
placeholder: "••••••••",
|
||
text: $loginPassword,
|
||
contentType: .password,
|
||
isSecure: true,
|
||
focusState: $focus,
|
||
field: .loginPassword,
|
||
onSubmit: submitLogin
|
||
)
|
||
|
||
ctaButton(label: "Sign in", action: submitLogin)
|
||
}
|
||
}
|
||
|
||
// MARK: - Register form
|
||
|
||
private var registerForm: some View {
|
||
VStack(spacing: 16) {
|
||
VStack(spacing: 4) {
|
||
AuthInputField(
|
||
label: "Username",
|
||
placeholder: "your_username",
|
||
text: $regUsername,
|
||
contentType: .username,
|
||
focusState: $focus,
|
||
field: .regUsername,
|
||
nextField: .regPassword
|
||
)
|
||
Text("3–32 characters: letters, numbers, _ or -")
|
||
.font(.caption)
|
||
.foregroundStyle(.tertiary)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.leading, 2)
|
||
}
|
||
|
||
VStack(spacing: 4) {
|
||
AuthInputField(
|
||
label: "Password",
|
||
placeholder: "••••••••",
|
||
text: $regPassword,
|
||
contentType: .newPassword,
|
||
isSecure: true,
|
||
focusState: $focus,
|
||
field: .regPassword,
|
||
nextField: .regConfirm
|
||
)
|
||
Text("At least 8 characters")
|
||
.font(.caption)
|
||
.foregroundStyle(.tertiary)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.leading, 2)
|
||
}
|
||
|
||
AuthInputField(
|
||
label: "Confirm password",
|
||
placeholder: "••••••••",
|
||
text: $regConfirm,
|
||
contentType: .newPassword,
|
||
isSecure: true,
|
||
focusState: $focus,
|
||
field: .regConfirm,
|
||
onSubmit: submitRegister
|
||
)
|
||
|
||
ctaButton(label: "Create account", action: submitRegister)
|
||
}
|
||
}
|
||
|
||
// MARK: - CTA button
|
||
|
||
private func ctaButton(label: String, action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
ZStack {
|
||
if authStore.isLoading {
|
||
ProgressView()
|
||
.tint(Color(uiColor: .systemBackground))
|
||
} else {
|
||
Text(label)
|
||
.font(.subheadline.bold())
|
||
.foregroundStyle(Color.ctaText)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 44)
|
||
}
|
||
.background(Color.amber)
|
||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||
.disabled(authStore.isLoading || !networkMonitor.isConnected)
|
||
.opacity(authStore.isLoading ? 0.8 : 1)
|
||
.animation(.easeInOut(duration: 0.15), value: authStore.isLoading)
|
||
.accessibilityLabel(label)
|
||
}
|
||
|
||
// MARK: - Actions
|
||
|
||
private func submitLogin() {
|
||
guard !authStore.isLoading else { return }
|
||
localError = nil
|
||
focus = nil
|
||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||
Task { await authStore.login(username: loginUsername, password: loginPassword) }
|
||
}
|
||
|
||
private func submitRegister() {
|
||
guard !authStore.isLoading else { return }
|
||
localError = nil
|
||
// Client-side validation
|
||
if regUsername.count < 3 || regUsername.count > 32 {
|
||
localError = "Username must be 3–32 characters."
|
||
return
|
||
}
|
||
if regPassword.count < 8 {
|
||
localError = "Password must be at least 8 characters."
|
||
return
|
||
}
|
||
if regPassword != regConfirm {
|
||
localError = "Passwords do not match."
|
||
return
|
||
}
|
||
focus = nil
|
||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||
Task { await authStore.register(username: regUsername, password: regPassword) }
|
||
}
|
||
}
|
||
|
||
// MARK: - Auth mode enum
|
||
|
||
private enum AuthMode: Equatable { case login, register }
|
||
|
||
// MARK: - Focus field enum
|
||
|
||
private enum AuthField: Hashable {
|
||
case loginUsername, loginPassword
|
||
case regUsername, regPassword, regConfirm
|
||
}
|
||
|
||
// MARK: - AuthInputField component
|
||
|
||
private struct AuthInputField: View {
|
||
let label: String
|
||
let placeholder: String
|
||
@Binding var text: String
|
||
var contentType: UITextContentType? = nil
|
||
var keyboardType: UIKeyboardType = .default
|
||
var isSecure: Bool = false
|
||
@FocusState.Binding var focusState: AuthField?
|
||
let field: AuthField
|
||
var nextField: AuthField? = nil
|
||
var onSubmit: (() -> Void)? = nil
|
||
|
||
private var isFocused: Bool { focusState == field }
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(label)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
|
||
Group {
|
||
if isSecure {
|
||
SecureField(placeholder, text: $text)
|
||
} else {
|
||
TextField(placeholder, text: $text)
|
||
.keyboardType(keyboardType)
|
||
.autocorrectionDisabled()
|
||
.textInputAutocapitalization(.never)
|
||
}
|
||
}
|
||
.textContentType(contentType)
|
||
.focused($focusState, equals: field)
|
||
.submitLabel(nextField != nil ? .next : .done)
|
||
.onSubmit {
|
||
if let next = nextField {
|
||
focusState = next
|
||
} else {
|
||
onSubmit?()
|
||
}
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.frame(height: 44)
|
||
.background(Color.fieldBackground)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||
.stroke(
|
||
isFocused ? Color.amber : Color.cardBorder,
|
||
lineWidth: isFocused ? 1.5 : 1
|
||
)
|
||
)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||
.animation(.spring(response: 0.2, dampingFraction: 0.7), value: isFocused)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Local color helpers
|
||
|
||
private extension Color {
|
||
static let appBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.09, green: 0.09, blue: 0.11, alpha: 1) : UIColor.systemGroupedBackground })
|
||
static let cardBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.14, green: 0.14, blue: 0.16, alpha: 1) : UIColor.secondarySystemGroupedBackground })
|
||
static let cardBorder = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(white: 0.25, alpha: 1) : UIColor.separator })
|
||
static let fieldBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.11, green: 0.11, blue: 0.13, alpha: 1) : UIColor.secondarySystemBackground })
|
||
static let ctaText = Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1)) // zinc-900
|
||
static let errorBackground = Color(red: 0.40, green: 0.05, blue: 0.05).opacity(0.40)
|
||
static let errorBorder = Color(red: 0.70, green: 0.20, blue: 0.20).opacity(0.60)
|
||
static let errorText = Color(red: 0.98, green: 0.60, blue: 0.60)
|
||
}
|