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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user