Add lead workflows, PDF signing, AR projects and photo gallery integration
This commit is contained in:
289
GasTankAR/Views/LoginView.swift
Normal file
289
GasTankAR/Views/LoginView.swift
Normal file
@ -0,0 +1,289 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user