Add lead workflows, PDF signing, AR projects and photo gallery integration

This commit is contained in:
Dennis Nemec
2026-11-08 17:49:39 +01:00
parent a343bde741
commit 8d18873412
82 changed files with 6401 additions and 34 deletions

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