feat: add iOS support and harden receipt scanning
This commit is contained in:
86
app/ios/ScanReceipts/Features/Auth/AppleSignInButton.swift
Normal file
86
app/ios/ScanReceipts/Features/Auth/AppleSignInButton.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
app/ios/ScanReceipts/Features/Auth/AuthFlowView.swift
Normal file
34
app/ios/ScanReceipts/Features/Auth/AuthFlowView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
109
app/ios/ScanReceipts/Features/Auth/LoginView.swift
Normal file
109
app/ios/ScanReceipts/Features/Auth/LoginView.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
110
app/ios/ScanReceipts/Features/Auth/SignupView.swift
Normal file
110
app/ios/ScanReceipts/Features/Auth/SignupView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user