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) }