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,113 @@
import Foundation
import Combine
/// Single source of truth for "who is signed in" every feature screen reads
/// `AppState.shared` (injected via `.environmentObject`) rather than talking
/// to `AuthAPI`/`KeychainTokenStore` directly, so there is exactly one place
/// that decides what "signed in" means and exactly one place a 401 anywhere
/// in the app routes back to the login screen.
@MainActor
final class AppState: ObservableObject {
enum AuthPhase: Equatable {
/// Checking the Keychain / pinging `/api/auth/session` on launch.
case checking
case signedOut
case signedIn(User)
}
@Published private(set) var phase: AuthPhase = .checking
@Published var lastError: String?
private var cancellables = Set<AnyCancellable>()
init() {
NotificationCenter.default.publisher(for: .sessionExpired)
.sink { [weak self] _ in
Task { @MainActor in self?.phase = .signedOut }
}
.store(in: &cancellables)
}
var currentUser: User? {
if case .signedIn(let user) = phase { return user }
return nil
}
/// Call once on app launch. If a token is already in the Keychain, this
/// validates it against the server (a locally-stored token could have
/// been revoked server-side, e.g. via "sign out everywhere") rather than
/// trusting its mere presence.
func bootstrap() async {
guard KeychainTokenStore.shared.token != nil else {
phase = .signedOut
return
}
do {
let response = try await AuthAPI.session()
phase = response.user.map(AuthPhase.signedIn) ?? .signedOut
} catch {
phase = .signedOut
}
}
func login(email: String, password: String) async {
lastError = nil
do {
let response = try await AuthAPI.login(email: email, password: password)
guard let token = response.token else {
// Should never happen X-Client: ios always yields a token
// (see src/app/api/auth/login/route.ts) but fail safe
// rather than silently "succeeding" signed out.
lastError = "Anmeldung fehlgeschlagen. Bitte erneut versuchen."
return
}
KeychainTokenStore.shared.token = token
// The login response only carries a small subset of the user
// (see LoginUser) immediately follow up with /session so the
// rest of the app has the full profile (isPro, scan quota, ...).
await bootstrap()
} catch {
lastError = error.localizedDescription
}
}
/// Mirrors `login(email:password:)` exactly, just against
/// `/api/auth/apple` instead same token-storage/bootstrap follow-up,
/// same fail-safe if a token somehow doesn't come back. `fullName` is
/// only ever non-nil the first time this account authorizes the app
/// (see `AuthAPI.appleSignIn`).
func signInWithApple(identityToken: String, fullName: String?) async {
lastError = nil
do {
let response = try await AuthAPI.appleSignIn(identityToken: identityToken, fullName: fullName)
guard let token = response.token else {
lastError = "Anmeldung mit Apple fehlgeschlagen. Bitte erneut versuchen."
return
}
KeychainTokenStore.shared.token = token
await bootstrap()
} catch {
lastError = error.localizedDescription
}
}
/// Returns true when a confirmation mail was sent (the account still
/// needs email verification before it can log in) matches the web
/// signup flow exactly, including its neutral response (see AuthAPI).
func signup(name: String?, email: String, password: String) async -> Bool {
lastError = nil
do {
_ = try await AuthAPI.signup(name: name, email: email, password: password)
return true
} catch {
lastError = error.localizedDescription
return false
}
}
func logout() async {
try? await AuthAPI.logout()
KeychainTokenStore.shared.token = nil
phase = .signedOut
}
}

View File

@@ -0,0 +1,30 @@
import SwiftUI
/// Shown once `AppState.phase == .signedIn`. Each tab's root view is built
/// independently (see the Features/ subfolders) and referenced here only by
/// its type name this file is the one integration point between them, so
/// none of the three needs to know the other two exist.
struct MainTabView: View {
var body: some View {
TabView {
// Features/Scan/ScanTabView.swift
ScanTabView()
.tabItem { Label("Scannen", systemImage: "camera.viewfinder") }
// Features/Receipts/ReceiptsListView.swift
ReceiptsListView()
.tabItem { Label("Belege", systemImage: "list.bullet.rectangle") }
// Features/Settings/SettingsView.swift
SettingsView()
.tabItem { Label("Konto", systemImage: "person.crop.circle") }
}
.tint(.zenithBlack)
// Solid white tab bar, no translucent system blur the design
// system rejects soft/blurred depth cues in favor of flat surfaces
// with a hard 1px edge (the hairline the system automatically draws
// at the top of an opaque tab bar stands in for that border here).
.toolbarBackground(Color.zenithSurface, for: .tabBar)
.toolbarBackground(.visible, for: .tabBar)
}
}

View File

@@ -0,0 +1,38 @@
import SwiftUI
/// Top-level router: which screen the user sees is entirely a function of
/// `AppState.phase`. Nothing else in the app should make navigation
/// decisions based on auth state route through here instead.
struct RootView: View {
@EnvironmentObject private var appState: AppState
var body: some View {
Group {
switch appState.phase {
case .checking:
LaunchView()
case .signedOut:
// Provided by Features/Auth/AuthFlowView.swift
AuthFlowView()
case .signedIn:
// Provided by Features/Root/MainTabView.swift
MainTabView()
}
}
.animation(.default, value: appState.phase)
}
}
private struct LaunchView: View {
var body: some View {
VStack(spacing: ZenithSpacing.xs) {
Image(systemName: "doc.text.viewfinder")
.font(.system(size: 44))
.foregroundStyle(.zenithBlack)
ProgressView()
.tint(.zenithBlack)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.zenithBg)
}
}

View File

@@ -0,0 +1,23 @@
import SwiftUI
@main
struct ScanReceiptsApp: App {
@StateObject private var appState = AppState()
init() {
// App-wide tint: the Zenith Silver system uses pure black for every
// interactive/structural accent (see Design/ZenithColors.swift)
// there is no separate "brand blue" the way most iOS apps have one.
UITabBar.appearance().tintColor = UIColor(Color.zenithBlack)
UINavigationBar.appearance().tintColor = UIColor(Color.zenithBlack)
}
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(appState)
.tint(.zenithBlack)
.task { await appState.bootstrap() }
}
}
}