Files
Gas-Tank-AR-App/GasTankAR/Views/LoginView.swift

290 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
private enum LoginStyle {
static let blue = Color(red: 0, green: 0.44, blue: 0.78)
static let background = Color(uiColor: .systemGroupedBackground)
}
struct LoginView: View {
let onLogin: (LoginSession) -> Void
@State private var model: LoginViewModel
@State private var isLoginRequested = false
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
init(loginUseCase: any LoginUseCase, onLogin: @escaping (LoginSession) -> Void) {
_model = State(initialValue: LoginViewModel(loginUseCase: loginUseCase))
self.onLogin = onLogin
}
var body: some View {
GeometryReader { geometry in
let isWide = geometry.size.width >= 850
&& geometry.size.width > geometry.size.height
&& !dynamicTypeSize.isAccessibilitySize
ScrollView {
if isWide {
HStack(spacing: 0) {
LoginBrandPanel(isWide: true)
.frame(width: geometry.size.width * 0.54)
LoginFormPanel(model: model, isLoginRequested: $isLoginRequested, isWide: true)
.frame(maxWidth: .infinity)
}
.frame(minHeight: geometry.size.height, alignment: .top)
} else {
VStack(spacing: 0) {
LoginBrandPanel(isWide: false)
.fixedSize(horizontal: false, vertical: true)
LoginFormPanel(model: model, isLoginRequested: $isLoginRequested, isWide: false)
.fixedSize(horizontal: false, vertical: true)
}
.frame(minHeight: geometry.size.height, alignment: .top)
}
}
.scrollDismissesKeyboard(.interactively)
.background {
if isWide {
HStack(spacing: 0) {
LoginStyle.blue.frame(width: geometry.size.width * 0.54)
LoginStyle.background
}
.ignoresSafeArea()
} else {
LoginStyle.background.ignoresSafeArea()
}
}
}
.tint(LoginStyle.blue)
.task(id: isLoginRequested) {
guard isLoginRequested else { return }
defer { isLoginRequested = false }
if let session = await model.signIn(), !Task.isCancelled {
onLogin(session)
}
}
}
}
private struct LoginBrandPanel: View {
let isWide: Bool
@ScaledMetric(relativeTo: .largeTitle) private var titleSize = 40
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if isWide {
Spacer(minLength: 60)
}
VStack(alignment: .leading, spacing: 24) {
Image("LoginLogo")
.resizable()
.scaledToFit()
.frame(width: isWide ? 96 : 72, height: isWide ? 96 : 72)
.clipShape(.rect(cornerRadius: 23))
.overlay {
RoundedRectangle(cornerRadius: 23)
.strokeBorder(.white.opacity(0.8), lineWidth: 2)
}
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 8) {
Text("DrachenGas")
.font(.system(size: titleSize, weight: .bold))
.lineLimit(1)
.minimumScaleFactor(0.5)
Text("Außendienst")
.font(.title3)
.foregroundStyle(.white.opacity(0.85))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if isWide {
Spacer(minLength: 60)
}
Text("Drachen-Propangas GmbH · Wetzlar")
.font(.footnote)
.foregroundStyle(.white.opacity(0.75))
.padding(.top, 32)
}
.padding(isWide ? 42 : 28)
.frame(maxWidth: .infinity, maxHeight: isWide ? .infinity : nil, alignment: .leading)
.background {
LoginStyle.blue.ignoresSafeArea(.container, edges: .top)
}
.foregroundStyle(.white)
}
}
private struct LoginFormPanel: View {
@Bindable var model: LoginViewModel
@Binding var isLoginRequested: Bool
let isWide: Bool
@FocusState private var isCodeFocused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Spacer(minLength: isWide ? 60 : 0)
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 8) {
Text("Anmelden")
.font(.largeTitle.bold())
Text("Mit deinem persönlichen Vertriebskürzel.")
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 10) {
Text("KÜRZEL")
.font(.caption.weight(.medium))
.tracking(1)
.foregroundStyle(.secondary)
HStack(spacing: 14) {
Image(systemName: "person")
.foregroundStyle(LoginStyle.blue)
.accessibilityHidden(true)
TextField("z. B. MKR", text: $model.salesCode)
.font(.title3.bold())
.tracking(2)
.textContentType(.username)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.submitLabel(.go)
.focused($isCodeFocused)
.accessibilityLabel("Vertriebskürzel")
.onSubmit(requestLogin)
}
.padding(18)
.background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 14))
.overlay {
RoundedRectangle(cornerRadius: 14)
.strokeBorder(
isCodeFocused ? LoginStyle.blue : Color.primary.opacity(0.08),
lineWidth: 1
)
}
.disabled(model.isLoading || isLoginRequested)
Text("Mindestens 3 Zeichen, z. B. MKR.")
.font(.footnote)
.foregroundStyle(.secondary)
}
if let errorMessage = model.errorMessage {
Label {
Text(errorMessage)
} icon: {
Image(systemName: "exclamationmark.circle")
}
.font(.callout)
.foregroundStyle(.red)
.accessibilityIdentifier("loginError")
}
Button(action: requestLogin) {
HStack(spacing: 10) {
if model.isLoading {
ProgressView().tint(.white)
}
Text(model.isLoading ? "Anmelden …" : "Anmelden")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.foregroundStyle(.white)
.background(
LoginStyle.blue.opacity(model.canSubmit ? 1 : 0.42),
in: .rect(cornerRadius: 14)
)
}
.buttonStyle(.plain)
.disabled(!model.canSubmit || isLoginRequested)
}
Spacer(minLength: 48)
LoginFooter()
}
.padding(isWide ? 48 : 28)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(LoginStyle.background)
}
private func requestLogin() {
guard model.canSubmit, !isLoginRequested else { return }
isCodeFocused = false
isLoginRequested = true
}
}
private struct LoginFooter: View {
@State private var isShowingContact = false
private let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "–"
var body: some View {
ViewThatFits(in: .horizontal) {
HStack(alignment: .firstTextBaseline, spacing: 16) {
LoginContactButton(isShowingContact: $isShowingContact)
Spacer(minLength: 0)
Text("Version \(version)")
}
VStack(alignment: .leading, spacing: 16) {
LoginContactButton(isShowingContact: $isShowingContact)
Text("Version \(version)")
}
}
.font(.footnote)
.foregroundStyle(.secondary)
.alert("Vertriebsleitung kontaktieren", isPresented: $isShowingContact) {
Button("OK", role: .cancel) {}
} message: {
Text("Bitte wende dich über die bekannten Kontaktwege an deine Vertriebsleitung. Eine direkte Kontaktmöglichkeit wird hier später ergänzt.")
}
}
}
private struct LoginContactButton: View {
@Binding var isShowingContact: Bool
var body: some View {
ViewThatFits(in: .horizontal) {
HStack(spacing: 4) {
Text("Kürzel vergessen?")
contactButton
}
VStack(alignment: .leading, spacing: 4) {
Text("Kürzel vergessen?")
contactButton
}
}
}
private var contactButton: some View {
Button("Vertriebsleitung kontaktieren") {
isShowingContact = true
}
.buttonStyle(.plain)
.foregroundStyle(LoginStyle.blue)
}
}
#Preview("Breit", traits: .fixedLayout(width: 1194, height: 834)) {
LoginView(loginUseCase: DefaultLoginUseCase(authenticationService: StubAuthenticationService())) { _ in }
}
#Preview("iPad Hochkant", traits: .fixedLayout(width: 834, height: 1194)) {
LoginView(loginUseCase: DefaultLoginUseCase(authenticationService: StubAuthenticationService())) { _ in }
}
#Preview("Schmal", traits: .fixedLayout(width: 390, height: 844)) {
LoginView(loginUseCase: DefaultLoginUseCase(authenticationService: StubAuthenticationService())) { _ in }
}
#Preview("Große Schrift", traits: .fixedLayout(width: 390, height: 844)) {
LoginView(loginUseCase: DefaultLoginUseCase(authenticationService: StubAuthenticationService())) { _ in }
.environment(\.dynamicTypeSize, .accessibility3)
}