feat: add iOS support and harden receipt scanning

This commit is contained in:
Timo
2026-08-20 23:48:26 +02:00
parent f5e06c32b0
commit cf1b799e1b
107 changed files with 11755 additions and 122 deletions

View File

@@ -0,0 +1,86 @@
import SwiftUI
import AuthenticationServices
import Foundation // PersonNameComponentsFormatter
/// "Mit Apple anmelden" placed below the email/password form on
/// `LoginView`. Wired to the real backend: `POST /api/auth/apple`
/// (`src/app/api/auth/apple/route.ts`) verifies the identity token Apple
/// hands back and returns a session exactly like `/api/auth/login` does
/// see `AuthAPI.appleSignIn` / `AppState.signInWithApple`.
struct AppleSignInButton: View {
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject private var appState: AppState
@State private var isSigningIn = false
/// Local, Apple-specific failures (token decode, user cancelled) kept
/// separate from `appState.lastError` so a cancelled Apple sheet doesn't
/// show a stale email/password error underneath it, or vice versa.
@State private var localErrorMessage: String?
var body: some View {
VStack(spacing: ZenithSpacing.unit * 2) {
SignInWithAppleButton(.signIn) { request in
request.requestedScopes = [.fullName, .email]
} onCompletion: { result in
handle(result)
}
// Apple's Human Interface Guidelines fix this button's own shape
// and colors not overridable to match Zenith Silver's 0px
// corners, and Apple explicitly does not allow restyling it.
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
.frame(maxWidth: .infinity, minHeight: 50)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.disabled(isSigningIn)
.overlay {
if isSigningIn {
ProgressView().tint(.white)
}
}
// A backend-side failure (invalid token, server error, ...) sets
// `appState.lastError`, which `LoginView` already renders above
// this button showing it a second time here would just
// duplicate the same message. Only a LOCAL, pre-network failure
// (decode error, user cancelled) is shown here.
if let localErrorMessage {
Text(localErrorMessage).zenithBodySmStyle(color: .zenithError)
}
}
}
private func handle(_ result: Result<ASAuthorization, Error>) {
switch result {
case .success(let authorization):
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
let tokenData = credential.identityToken,
let identityToken = String(data: tokenData, encoding: .utf8) else {
localErrorMessage = "Apple-Anmeldung fehlgeschlagen. Bitte erneut versuchen."
return
}
localErrorMessage = nil
// Only present on the account's very first authorization with
// this app nil on every later sign-in, which is expected and
// fine (AuthAPI.appleSignIn/the backend both treat it as optional).
let fullName = credential.fullName.flatMap { components -> String? in
let formatted = PersonNameComponentsFormatter.localizedString(from: components, style: .default)
return formatted.isEmpty ? nil : formatted
}
isSigningIn = true
Task {
await appState.signInWithApple(identityToken: identityToken, fullName: fullName)
isSigningIn = false
}
case .failure(let error):
// The user cancelling the Apple Sign-In sheet also lands here
// (ASAuthorizationError.canceled) that's expected, not a
// failure worth surfacing as an error.
if (error as? ASAuthorizationError)?.code == .canceled {
localErrorMessage = nil
} else {
localErrorMessage = error.localizedDescription
}
}
}
}

View File

@@ -0,0 +1,34 @@
import SwiftUI
/// Shown for `AppState.phase == .signedOut` (see `App/RootView.swift`, which
/// depends on this exact type name and its zero-argument initializer). Owns
/// simple in-feature navigation between "Anmelden", "Registrieren", and the
/// post-signup "bitte bestätige deine E-Mail" screen none of that belongs
/// in `AppState` since none of it affects whether the user is actually
/// signed in; `AppState.phase` only changes once a real session exists.
struct AuthFlowView: View {
private enum Screen: Equatable {
case login
case signup
case verificationPending(email: String)
}
@State private var screen: Screen = .login
var body: some View {
Group {
switch screen {
case .login:
LoginView(onSwitchToSignup: { screen = .signup })
case .signup:
SignupView(
onSignupSucceeded: { email in screen = .verificationPending(email: email) },
onSwitchToLogin: { screen = .login }
)
case .verificationPending(let email):
VerificationPendingView(email: email, onGoToLogin: { screen = .login })
}
}
.animation(.default, value: screen)
}
}

View File

@@ -0,0 +1,109 @@
import SwiftUI
/// "Anmelden" screen the default screen of `AuthFlowView`. Talks to auth
/// state exclusively through `AppState.login(email:password:)`; never calls
/// `AuthAPI` directly (see `AppState`'s own doc comment for why).
struct LoginView: View {
@EnvironmentObject private var appState: AppState
/// Switches `AuthFlowView` to the signup screen.
var onSwitchToSignup: () -> Void
@State private var email = ""
@State private var password = ""
@State private var isSubmitting = false
private var canSubmit: Bool {
!email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !password.isEmpty
&& !isSubmitting
}
var body: some View {
ScrollView {
VStack(spacing: ZenithSpacing.md) {
VStack(spacing: ZenithSpacing.xs) {
Image(systemName: "doc.text.viewfinder")
.font(.system(size: 40))
.foregroundStyle(Color.zenithBlack)
Text("ScanReceipts")
.zenithHeadlineLgStyle()
}
.padding(.top, ZenithSpacing.lg)
VStack(spacing: ZenithSpacing.sm) {
ZenithTextField(
label: "E-Mail",
text: $email,
keyboardType: .emailAddress,
textContentType: .username,
autocapitalization: .never,
autocorrectionDisabled: true
)
ZenithTextField(
label: "Passwort",
text: $password,
isSecure: true,
textContentType: .password
)
}
if let error = appState.lastError {
Text(error)
.zenithBodySmStyle(color: .zenithError)
.multilineTextAlignment(.center)
}
Button {
Task { await submit() }
} label: {
Group {
if isSubmitting {
ProgressView()
.tint(.white)
} else {
Text("Anmelden")
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.zenithPrimary)
.disabled(!canSubmit)
HStack(spacing: ZenithSpacing.sm) {
ZenithDivider()
Text("oder")
.zenithBodySmStyle()
ZenithDivider()
}
// No outer `.frame(height:)` here AppleSignInButton sizes
// its own button (minHeight: 50) and needs the flexibility
// to grow when it shows its own inline error text below it.
AppleSignInButton()
Button("Noch kein Konto? Registrieren") {
appState.lastError = nil
onSwitchToSignup()
}
.buttonStyle(.zenithPlain)
.padding(.top, ZenithSpacing.xs)
}
.padding(.horizontal, ZenithSpacing.md)
.padding(.bottom, ZenithSpacing.lg)
}
.scrollDismissesKeyboard(.interactively)
.background(Color.zenithBg)
}
private func submit() async {
guard canSubmit else { return }
isSubmitting = true
defer { isSubmitting = false }
await appState.login(
email: email.trimmingCharacters(in: .whitespacesAndNewlines),
password: password
)
}
}

View File

@@ -0,0 +1,110 @@
import SwiftUI
/// "Registrieren" screen. On success this does NOT sign the user in the
/// backend requires the emailed confirmation link to be opened first, exactly
/// like the web signup flow (see `AppState.signup(name:email:password:)`)
/// so success routes to `VerificationPendingView` via `onSignupSucceeded`,
/// not into `AppState`.
struct SignupView: View {
@EnvironmentObject private var appState: AppState
var onSignupSucceeded: (_ email: String) -> Void
var onSwitchToLogin: () -> Void
@State private var name = ""
@State private var email = ""
@State private var password = ""
@State private var isSubmitting = false
private var canSubmit: Bool {
!email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !password.isEmpty
&& !isSubmitting
}
var body: some View {
ScrollView {
VStack(spacing: ZenithSpacing.md) {
Text("Konto erstellen")
.zenithHeadlineLgStyle()
.padding(.top, ZenithSpacing.lg)
VStack(spacing: ZenithSpacing.sm) {
ZenithTextField(
label: "Name (optional)",
text: $name,
textContentType: .name,
autocapitalization: .words
)
ZenithTextField(
label: "E-Mail",
text: $email,
keyboardType: .emailAddress,
textContentType: .username,
autocapitalization: .never,
autocorrectionDisabled: true
)
ZenithTextField(
label: "Passwort",
text: $password,
isSecure: true,
textContentType: .newPassword
)
}
if let error = appState.lastError {
Text(error)
.zenithBodySmStyle(color: .zenithError)
.multilineTextAlignment(.center)
}
Button {
Task { await submit() }
} label: {
Group {
if isSubmitting {
ProgressView()
.tint(.white)
} else {
Text("Konto erstellen")
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.zenithPrimary)
.disabled(!canSubmit)
Button("Schon ein Konto? Anmelden") {
appState.lastError = nil
onSwitchToLogin()
}
.buttonStyle(.zenithPlain)
.padding(.top, ZenithSpacing.xs)
}
.padding(.horizontal, ZenithSpacing.md)
.padding(.bottom, ZenithSpacing.lg)
}
.scrollDismissesKeyboard(.interactively)
.background(Color.zenithBg)
}
private func submit() async {
guard canSubmit else { return }
isSubmitting = true
defer { isSubmitting = false }
let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let succeeded = await appState.signup(
name: trimmedName.isEmpty ? nil : trimmedName,
email: trimmedEmail,
password: password
)
if succeeded {
onSignupSucceeded(trimmedEmail)
}
}
}

View File

@@ -0,0 +1,49 @@
import SwiftUI
/// Shown after a successful `POST /api/auth/signup` the account exists but
/// is inert until the emailed confirmation link is opened (deliberate
/// anti-abuse design, mirrored from the web signup flow see
/// `AppState.signup(name:email:password:)`). This screen never signs the
/// user in itself; it just points back to `LoginView` once they've verified
/// (on this device or any other) and come back.
struct VerificationPendingView: View {
let email: String
var onGoToLogin: () -> Void
var body: some View {
VStack(spacing: ZenithSpacing.md) {
Spacer()
Image(systemName: "envelope.badge")
.font(.system(size: 48))
.foregroundStyle(Color.zenithBlack)
Text("Bitte bestätige deine E-Mail")
.zenithHeadlineLgStyle()
.multilineTextAlignment(.center)
(
Text("Wir haben einen Bestätigungslink an ")
+ Text(email).fontWeight(.semibold)
+ Text(" gesendet. Öffne den Link auf diesem oder einem anderen Gerät, um dein Konto zu aktivieren, und melde dich anschließend hier an.")
)
.zenithBodyStyle(color: .zenithMuted)
.multilineTextAlignment(.center)
.padding(.horizontal, ZenithSpacing.xs)
Spacer()
Button {
onGoToLogin()
} label: {
Text("Zum Login")
.frame(maxWidth: .infinity)
}
.buttonStyle(.zenithPrimary)
}
.padding(.horizontal, ZenithSpacing.md)
.padding(.vertical, ZenithSpacing.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.zenithBg)
}
}