feat: add iOS support and harden receipt scanning
This commit is contained in:
113
app/ios/ScanReceipts/App/AppState.swift
Normal file
113
app/ios/ScanReceipts/App/AppState.swift
Normal 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
|
||||
}
|
||||
}
|
||||
30
app/ios/ScanReceipts/App/MainTabView.swift
Normal file
30
app/ios/ScanReceipts/App/MainTabView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
38
app/ios/ScanReceipts/App/RootView.swift
Normal file
38
app/ios/ScanReceipts/App/RootView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
23
app/ios/ScanReceipts/App/ScanReceiptsApp.swift
Normal file
23
app/ios/ScanReceipts/App/ScanReceiptsApp.swift
Normal 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user