290 lines
11 KiB
Swift
290 lines
11 KiB
Swift
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)
|
||
}
|