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

188
app/ios/README.md Normal file
View File

@@ -0,0 +1,188 @@
# ScanReceipts — iOS App
Native SwiftUI client for the same backend the web app (`../..`) already
runs. There is no separate mobile backend and no separate database — this
app authenticates against the same Next.js API (`/api/**`) and the same
Postgres `users`/`receipts`/`projects` tables. A Pro account bought on the
web is a Pro account in the app the moment you log in with the same
credentials, because both clients read `users.plan` / `users.expiresAt` from
the identical row.
Written from the [architecture plan](../../docs/ios-app-plan.html) (also
published as a Claude artifact during planning) — read that first for the
*why*; this file is the *how*.
## Status
This scaffold was generated without access to Xcode/macOS (built on Windows),
so nothing here has been compiled yet. What exists:
- **Backend (already shipped, in the main repo, not this folder):** a
Bearer-token auth path alongside the existing cookie/session auth, so a
native client without a cookie jar can authenticate. See
`src/lib/auth/session.ts` (`getCurrentUser(request)`), `src/lib/auth/csrf.ts`
(`isCsrfExempt`), and the `X-Client: ios` header handling in
`src/app/api/auth/login/route.ts`. Verified end-to-end against a live dev
server (login → Bearer-authenticated `/api/receipts` round-trip → logout).
- **Sign in with Apple, backend + app, fully wired** — `POST /api/auth/apple`
(`src/app/api/auth/apple/route.ts`) verifies the identity token
(`src/lib/auth/apple.ts`, hand-rolled RS256/JWK verification against
`node:crypto`, no new dependency) and issues a session exactly like
`/api/auth/login`; `AppleSignInButton.swift` calls it via
`AppState.signInWithApple`. The token-verification logic itself (signature,
issuer, audience, expiry, tamper/forged-key rejection) was proven correct
with a 9-case self-signed-JWT test — there's no way to get a REAL Apple
identity token without a physical device and an enrolled Apple Developer
account, so that's the strongest verification possible from here. The route
wiring (HTTP status codes, CSRF exemption, rate limiting) was NOT verified
live end-to-end: the local dev server broke (every route, including
pre-existing ones, started 404ing) partway through testing because another
process was concurrently editing files in this repo (`src/lib/schema/receipt.ts`
changed mid-session) — not something caused by this change. `npx tsc
--noEmit` is clean. Re-run a live check (`curl -X POST .../api/auth/apple`)
once the repo is quiet before relying on this in production.
- **Apple In-App Purchase, backend + app, real (not stubbed) purchase
engine** — `Features/Settings/StoreKitPurchaseService.swift` is a real
StoreKit 2 implementation (product loading with actual App-Store-localized
prices, purchase, transaction verification, restore) wired into
`PaywallView`; `Configuration.storekit` lets it be tested in the Simulator
with zero Apple Developer account (see setup below). Server-side,
`POST /api/webhooks/apple` (`src/app/api/webhooks/apple/route.ts`,
`src/lib/billing/appleIAP.ts`) verifies App Store Server Notifications V2
using Apple's own official `@apple/app-store-server-library` (full x5c
certificate-chain verification against `src/lib/billing/certs/AppleRootCA-G3.cer`
— downloaded from apple.com, NOT hand-rolled crypto, deliberately unlike
the Sign-in-with-Apple JWKS check above, because chain-of-trust validation
is a much easier place to get subtly wrong) and grants/revokes
`users.plan` exactly like the existing Stripe webhook does. A purchase is
tied back to the account via `User.appleAccountToken` (iOS) /
`userIdFromAppAccountToken` (backend) — a losslessly-reversible UUID
built from the user's own id, no extra stored mapping needed.
**Verified:** the full certificate-chain verification path was proven
against a self-built 3-tier test CA (root → intermediate → leaf, signed
with the same Apple-specific X.509 extensions the library requires) —
valid chain accepted, tampered payload/untrusted root/wrong bundle
ID/wrong environment all correctly rejected (11/11 checks), and the
appAccountToken round-trip was verified byte-for-byte. **Not verified:**
an actual end-to-end purchase, since that needs a real device, an
enrolled Apple Developer account, and real App Store Connect products —
none of which exist yet. `npx tsc --noEmit` is clean.
- **Sign in with Apple, backend + app, fully wired** — `POST /api/auth/apple`
(`src/app/api/auth/apple/route.ts`) verifies the identity token
(`src/lib/auth/apple.ts`, hand-rolled RS256/JWK verification against
`node:crypto`, no new dependency) and issues a session exactly like
`/api/auth/login`; `AppleSignInButton.swift` calls it via
`AppState.signInWithApple`. The token-verification logic itself (signature,
issuer, audience, expiry, tamper/forged-key rejection) was proven correct
with a 9-case self-signed-JWT test — there's no way to get a REAL Apple
identity token without a physical device and an enrolled Apple Developer
account, so that's the strongest verification possible from here. The route
wiring (HTTP status codes, CSRF exemption, rate limiting) was NOT verified
live end-to-end: the local dev server broke (every route, including
pre-existing ones, started 404ing) partway through testing because another
process was concurrently editing files in this repo (`src/lib/schema/receipt.ts`
changed mid-session) — not something caused by this change. `npx tsc
--noEmit` is clean. Re-run a live check (`curl -X POST .../api/auth/apple`)
once the repo is quiet before relying on this in production.
- **This app's networking/model foundation** (`ScanReceipts/Networking`,
`ScanReceipts/Models`, `ScanReceipts/App`): API client, Keychain token
storage, and Codable models matching the backend's JSON exactly.
- **Visual design** (`ScanReceipts/Design/`): the web app's actual "Zenith
Silver" design system (`DESIGN (1).md`, `tailwind.config.ts`), ported —
same palette, same 4px spacing scale, 0px corner radius everywhere, no
shadows, and the SAME font files (`Resources/Fonts/*.ttf` — real Hanken
Grotesk / Inter / JetBrains Mono variable fonts pulled from the canonical
google/fonts repo, registered via `UIAppFonts`, not a system-font
approximation). `ZenithStatusBadge` ports the web dashboard's 3-tier
receipt-status badge (`StatusBadge.tsx`) color-for-color. Every screen in
`Features/` consumes this — see `Design/*.swift`'s doc comments for the
full component list (`ZenithButtonStyle`, `ZenithTextField`, `ZenithCard`,
`ZenithDivider`, `ZenithStatusBadge`, `ZenithChip`). The two native
controls Apple doesn't allow restyling (`SignInWithAppleButton`, the
VisionKit camera / `UIActivityViewController` share sheet) keep their own
required system appearance — that's an App Store requirement, not a gap.
- **Feature screens** (`ScanReceipts/Features/*`): all four areas are built
against that foundation — Auth (login/signup/email-verification/Sign in
with Apple UI), Scan (VisionKit document camera + Photos fallback + review
form), Receipts (list/search/detail-edit/delete/export + share sheet), and
Settings (profile/Pro status/projects/change password/delete
account/paywall). Cross-checked for naming collisions and that every type
`MainTabView.swift`/`RootView.swift` depend on (`AuthFlowView`,
`ScanTabView`, `ReceiptsListView`, `SettingsView`) exists with a working
zero-arg initializer. Not yet opened in Xcode. Expect the first
`xcodegen generate` + build on a Mac to surface small issues (an unused
import, a SwiftUI modifier from a newer/older SDK than assumed) — normal
for ~4,100 lines of Swift written without a compiler in the loop; nothing
here should need a structural rewrite.
## First-time setup (macOS)
```bash
brew install xcodegen
cd app/ios
xcodegen generate
open ScanReceipts.xcodeproj
```
In Xcode: **Signing & Capabilities** → set your own Team. `project.yml`
leaves `DEVELOPMENT_TEAM` blank on purpose — set it locally, don't commit it.
**To test in-app purchases without an Apple Developer account or real
money:** `Configuration.storekit` defines the three Pro products locally.
Enable it once per scheme: **Product → Scheme → Edit Scheme → Run →
Options → StoreKit Configuration → `Configuration.storekit`**. Purchases in
the Simulator then run against this local file — no App Store Connect
product configuration or real payment is involved. This is a genuinely
separate thing from the *real* products a shipped build needs registered in
App Store Connect with the exact same product identifiers (see
`PurchaseService.swift`'s `PurchaseProductID`).
Point the app at a real backend to test against by editing
`ScanReceipts/Networking/APIEnvironment.swift`: `.local` targets
`http://localhost:3901` (the web repo's `dev-verify` launch config — start it
with the web app's own tooling, a plain `npm run dev` on port 3000 also
works if you change the port here), `.production` is a placeholder domain to
replace before shipping.
## Architecture
```
ScanReceipts/
App/ Entry point, AppState (auth), root/tab routing
Networking/ APIClient, per-resource API namespaces, Keychain
Models/ Codable structs mirroring src/lib/schema/*.ts
Support/ Small standalone helpers (ISO8601 date parsing, ...)
Features/
Auth/ Login, signup, email-verification-pending, Sign in with Apple (fully wired — see below)
Scan/ VisionKit camera capture → AI extraction → review → save
Receipts/ List, detail/edit, delete, export (CSV/XLSX/PDF)
Settings/ Profile, plan/Pro status, projects (folders), logout, delete account
```
One rule that keeps this tree maintainable: **feature code never calls
`URLSession` or reads the Keychain directly.** Everything goes through
`Networking/*API.swift` (`AuthAPI`, `ReceiptsAPI`, `ProjectsAPI`, `ExportAPI`)
and `AppState`. If a screen needs a new backend call, add it to the relevant
`*API.swift` file rather than reaching around it.
## What's deliberately NOT done yet
- **An actual App Store Connect listing.** The purchase engine and webhook
are both real, but there is still no Apple Developer Program enrollment,
no App Store Connect app record, and no real product configuration — so
`APPLE_APP_APPLE_ID` stays unset (see `.env.example`) and real-money
purchases genuinely cannot happen yet. `Configuration.storekit` covers
local Simulator testing in the meantime.
- **Push notifications** for "scan finished" — not built; a nice-to-have per
the plan, not required for a working v1.
- **iPad layout** — `project.yml` targets iPhone only (`TARGETED_DEVICE_FAMILY: "1"`).
## Testing against the shared database
Because this app and the web dashboard hit the same API, the fastest way to
sanity-check a screen while developing is: create/verify a user via the web
app's normal signup flow (or `node scripts/create-admin.ts` in the main repo
for an instant Pro account), then log into the iOS app (Simulator, `.local`
environment) with the same credentials. Anything synced from one side shows
up on the other on next refresh — there is no separate mobile dataset to
seed.

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() }
}
}
}

View File

@@ -0,0 +1,137 @@
{
"identifier" : "4BAA6E1B-93CE-44E6-8260-D22EE41F8F62",
"nonRenewingSubscriptions" : [
],
"products" : [
{
"displayPrice" : "59.99",
"familyShareable" : false,
"internalID" : "37C48A2F-CFC8-4641-B99E-D92FDE0BDC94",
"localizations" : [
{
"description" : "Lebenslanger unbegrenzter Zugriff ohne Folgekosten inklusive aller Updates.",
"displayName" : "Lifetime",
"locale" : "de_DE"
}
],
"productID" : "app.scan-receipts.ios.pro.lifetime",
"referenceName" : "Pro Lifetime",
"type" : "NonConsumable"
}
],
"settings" : {
"_failTransactionsEnabled" : false,
"_locale" : "de_DE",
"_storefront" : "DEU",
"_storeKitErrors" : [
{
"current" : null,
"name" : "Load Products"
},
{
"current" : null,
"name" : "Purchase"
},
{
"current" : null,
"name" : "Verification"
},
{
"current" : null,
"name" : "App Store Sync"
},
{
"current" : null,
"name" : "Subscription Status"
},
{
"current" : null,
"name" : "App Transaction"
},
{
"current" : null,
"name" : "Manage Subscriptions Sheet"
},
{
"current" : null,
"name" : "Refund Request Sheet"
},
{
"current" : null,
"name" : "Offer Code Redeem Sheet"
}
]
},
"subscriptionGroups" : [
{
"id" : "A1267F08-8B3C-4DF2-A357-56A3870FB1BD",
"localizations" : [
],
"name" : "ScanReceipts Pro",
"subscriptions" : [
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "4.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "357EBEDE-0F5C-4787-A109-A3D4C11914A5",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "Unbegrenzt scannen, Dual-Sheet Excel & Buchhaltungs-CSV. Ideal zum Ausprobieren.",
"displayName" : "Wochen-Pass",
"locale" : "de_DE"
}
],
"productID" : "app.scan-receipts.ios.pro.weekly",
"recurringSubscriptionPeriod" : "P1W",
"referenceName" : "Pro Weekly",
"subscriptionGroupID" : "A1267F08-8B3C-4DF2-A357-56A3870FB1BD",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
},
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "39.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "1D1C3AAA-B327-41DD-B0A2-3EFD35353A7C",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "Volle 12 Monate unbegrenzte Belege, Dual-Sheet Excel & Prioritäts-Support. Bestes Preis-Leistungs-Verhältnis.",
"displayName" : "Jahres-Pass",
"locale" : "de_DE"
}
],
"productID" : "app.scan-receipts.ios.pro.annual",
"recurringSubscriptionPeriod" : "P1Y",
"referenceName" : "Pro Annual",
"subscriptionGroupID" : "A1267F08-8B3C-4DF2-A357-56A3870FB1BD",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
}
]
}
],
"version" : {
"major" : 3,
"minor" : 0
}
}

View File

@@ -0,0 +1,96 @@
import SwiftUI
/// Buttons per `DESIGN (1).md` Components Buttons: "Solid #000000
/// background with #FFFFFF text for primary actions. 1px solid #000000
/// border with no fill for secondary. All buttons are rectangular [0px
/// radius] with no padding-inline under 24px." And Elevation: "On hover,
/// elements do not lift; they shift color... a button fill turns from Black
/// to Slate Blue-Grey" the pressed state here plays the role "hover" plays
/// on the web.
/// Every Zenith button style dims to this opacity when `.disabled(true)`
/// centralised here so a disabled `.zenithPrimary` etc. dims automatically,
/// the way `.borderedProminent`/`.bordered` do out of the box. Without this,
/// each call site would need its own manual `.opacity(isEnabled ? 1 : 0.4)`,
/// which is easy to forget (an earlier pass of this app's screens did, in
/// fact, forget it in a couple of places before this was added centrally).
private let zenithDisabledOpacity: Double = 0.4
struct ZenithPrimaryButtonStyle: ButtonStyle {
var isDestructive = false
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.zenithBodyMd)
.fontWeight(.medium)
.foregroundStyle(.white)
.frame(maxWidth: .infinity, minHeight: 50)
.padding(.horizontal, ZenithSpacing.md)
.background(fill(pressed: configuration.isPressed))
// Sharp corners are the point of this system a plain
// Rectangle fill, never `.cornerRadius`.
.opacity(isEnabled ? 1 : zenithDisabledOpacity)
}
private func fill(pressed: Bool) -> Color {
if isDestructive { return pressed ? Color.zenithError.opacity(0.8) : .zenithError }
return pressed ? .zenithSlate : .zenithBlack
}
}
struct ZenithSecondaryButtonStyle: ButtonStyle {
var isDestructive = false
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.zenithBodyMd)
.fontWeight(.medium)
.foregroundStyle(isDestructive ? Color.zenithError : Color.zenithBlack)
.frame(maxWidth: .infinity, minHeight: 50)
.padding(.horizontal, ZenithSpacing.md)
.background(Color.zenithSurface)
.overlay(
// Border THICKENS on press instead of the fill lifting
// same "no lift, shift instead" rule as the primary style.
Rectangle()
.strokeBorder(
isDestructive ? Color.zenithError : Color.zenithBlack,
lineWidth: configuration.isPressed ? 2 : 1
)
)
.opacity(isEnabled ? 1 : zenithDisabledOpacity)
}
}
/// A plain-text button with no fill/border at all for tertiary actions
/// ("Noch kein Konto? Registrieren", "Abbrechen") that shouldn't compete
/// visually with a screen's primary/secondary buttons.
struct ZenithPlainButtonStyle: ButtonStyle {
var color: Color = .zenithMuted
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.zenithBodySm)
.foregroundStyle(configuration.isPressed ? color.opacity(0.6) : color)
.opacity(isEnabled ? 1 : zenithDisabledOpacity)
}
}
extension ButtonStyle where Self == ZenithPrimaryButtonStyle {
static var zenithPrimary: ZenithPrimaryButtonStyle { ZenithPrimaryButtonStyle() }
static var zenithPrimaryDestructive: ZenithPrimaryButtonStyle { ZenithPrimaryButtonStyle(isDestructive: true) }
}
extension ButtonStyle where Self == ZenithSecondaryButtonStyle {
static var zenithSecondary: ZenithSecondaryButtonStyle { ZenithSecondaryButtonStyle() }
static var zenithSecondaryDestructive: ZenithSecondaryButtonStyle { ZenithSecondaryButtonStyle(isDestructive: true) }
}
extension ButtonStyle where Self == ZenithPlainButtonStyle {
static var zenithPlain: ZenithPlainButtonStyle { ZenithPlainButtonStyle() }
}

View File

@@ -0,0 +1,35 @@
import SwiftUI
/// Cards per `DESIGN (1).md` Components Cards: "White backgrounds with
/// 1px borders in #E2E8F0. No shadows. Content within cards should follow
/// the 24px internal padding rule." And Shapes: 0px radius everywhere.
struct ZenithCardModifier: ViewModifier {
var padding: CGFloat = ZenithSpacing.cardPadding
func body(content: Content) -> some View {
content
.padding(padding)
.background(Color.zenithSurface)
.overlay(Rectangle().strokeBorder(Color.zenithBorder, lineWidth: 1))
// Deliberately no `.cornerRadius` and no `.shadow` both are
// explicitly rejected by the design system ("rejects traditional
// shadows and depth", "0px radius" everywhere).
}
}
extension View {
func zenithCard(padding: CGFloat = ZenithSpacing.cardPadding) -> some View {
modifier(ZenithCardModifier(padding: padding))
}
/// Strips the default iOS `List`/`Form` chrome (rounded row groups,
/// system grey background, inset grouping) toward the flat,
/// bordered-card look the rest of this design system uses. Apply to a
/// `List`/`Form`; still combine with `.listRowBackground(Color.zenithSurface)`
/// and `.listRowSeparatorTint(Color.zenithBorder)` on individual rows if
/// you want row separators to match instead of disappearing.
func zenithListBackground() -> some View {
scrollContentBackground(.hidden)
.background(Color.zenithBg)
}
}

View File

@@ -0,0 +1,81 @@
import SwiftUI
/// The web app's "Zenith Silver" palette (see `tailwind.config.ts`'s `zenith`
/// color group and `DESIGN (1).md` in the main repo root), ported 1:1 as hex
/// values this is a single fixed light palette, not a light/dark pair: the
/// web app itself has no dark mode, and "Architectural Minimalist" is
/// deliberately monochromatic, so inventing a dark variant here would not be
/// matching the web app's design, it would be a different one. If dark mode
/// is wanted later, that's a deliberate follow-up design decision, not a gap
/// in this port.
extension Color {
init(zenithHex hex: UInt32) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double(hex & 0xFF) / 255,
opacity: 1
)
}
// MARK: - Base surfaces
/// Page background "Zenith Silver" itself.
static let zenithBg = Color(zenithHex: 0xF6F9FF)
/// Card/container fill Level 1 in the system's elevation model.
static let zenithSurface = Color(zenithHex: 0xFFFFFF)
static let zenithDim = Color(zenithHex: 0xD4DBE2)
static let zenithLow = Color(zenithHex: 0xEEF4FC)
static let zenithContainer = Color(zenithHex: 0xE8EEF6)
static let zenithHigh = Color(zenithHex: 0xE3E9F1)
static let zenithHighest = Color(zenithHex: 0xDDE3EB)
// MARK: - Structure
/// The one border color used everywhere at 1px (2px on an active/focused
/// control see `ZenithTextField`).
static let zenithBorder = Color(zenithHex: 0xE2E8F0)
static let zenithBorderDark = Color(zenithHex: 0xC4C7C9)
// MARK: - Type
static let zenithText = Color(zenithHex: 0x161C22)
static let zenithMuted = Color(zenithHex: 0x444749)
static let zenithSubtle = Color(zenithHex: 0x747779)
// MARK: - Ink & accent
/// Pure black primary buttons, headline type, structural strokes.
static let zenithBlack = Color(zenithHex: 0x000000)
/// Slate blue-grey secondary actions, metadata, the color a primary
/// button's fill shifts to on a pressed/hover state instead of "lifting".
static let zenithSlate = Color(zenithHex: 0x475569)
static let zenithAccent = Color(zenithHex: 0x1E293B)
static let zenithError = Color(zenithHex: 0xBA1A1A)
// MARK: - Status tiers
//
// Ported directly from `src/components/dashboard/StatusBadge.tsx`
// (`getStatusTierMeta`) these are standard Tailwind emerald/amber/blue
// shades, NOT part of the Zenith Silver monochrome palette itself. The
// web app deliberately allows exactly these three status colors as the
// one place the otherwise-monochrome system admits color, so this app
// should use them only for the same purpose (receipt status), not as a
// general accent.
static let zenithScannedBg = Color(zenithHex: 0xECFDF5)
static let zenithScannedText = Color(zenithHex: 0x065F46)
static let zenithScannedBorder = Color(zenithHex: 0xA7F3D0)
static let zenithScannedDot = Color(zenithHex: 0x10B981)
static let zenithPendingBg = Color(zenithHex: 0xFFFBEB)
static let zenithPendingText = Color(zenithHex: 0x92400E)
static let zenithPendingBorder = Color(zenithHex: 0xFDE68A)
static let zenithPendingDot = Color(zenithHex: 0xF59E0B)
static let zenithConfirmedBg = Color(zenithHex: 0xEFF6FF)
static let zenithConfirmedText = Color(zenithHex: 0x1D4ED8)
static let zenithConfirmedBorder = Color(zenithHex: 0xBFDBFE)
static let zenithConfirmedDot = Color(zenithHex: 0x2563EB)
}

View File

@@ -0,0 +1,13 @@
import SwiftUI
/// A 1px rule in the system's one border color `DESIGN (1).md`
/// Components Lists: "Separated by 1px horizontal dividers in #E2E8F0."
/// Prefer this over the bare `Divider()`, whose color/weight follow the
/// system theme rather than this app's fixed palette.
struct ZenithDivider: View {
var body: some View {
Rectangle()
.fill(Color.zenithBorder)
.frame(height: 1)
}
}

View File

@@ -0,0 +1,24 @@
import CoreGraphics
/// The web app's 4px-base spacing scale (`DESIGN (1).md` Layout & Spacing:
/// `spacing.unit: 4px`, `stack-sm/md/lg/xl`, `margin-mobile`). Use these
/// instead of ad-hoc padding numbers so spacing stays consistent with the
/// web app's rhythm.
enum ZenithSpacing {
/// The 4px base unit itself for the rare one-off that doesn't fit a
/// named step below.
static let unit: CGFloat = 4
static let xs: CGFloat = 8 // stack-sm
static let sm: CGFloat = 16 // margin-mobile
static let md: CGFloat = 24 // stack-md / card internal padding
static let lg: CGFloat = 48 // stack-lg
static let xl: CGFloat = 80 // stack-xl
/// Card internal padding `DESIGN (1).md` Components Cards:
/// "Content within cards should follow the 24px internal padding rule."
static let cardPadding: CGFloat = md
/// Screen-edge margin on a phone-width layout (mobile margin, not the
/// 64px desktop margin, which doesn't apply on iPhone).
static let screenMargin: CGFloat = sm
}

View File

@@ -0,0 +1,125 @@
import SwiftUI
/// Ported from `src/components/dashboard/StatusBadge.tsx` the web
/// dashboard's "3-tier status badges" (see `PROJECT.md`'s feature #12).
/// Keep this resolver logic in sync with `resolveReceiptStatusTier` /
/// `receiptNeedsAttention` (`src/lib/ai/recalculate.ts`) on the web side.
enum ReceiptStatusTier {
case scanned
case pendingReview
case confirmed
/// Mirrors `resolveReceiptStatusTier`: a manual confirmation always wins,
/// then math/confidence problems route to "needs review", and everything
/// else that at least parsed successfully is "scanned".
static func resolve(_ receipt: Receipt) -> ReceiptStatusTier {
let validation = receipt.validation
if validation.userConfirmed == true { return .confirmed }
if validation.needsUserReview || !validation.isMathValid { return .pendingReview }
if receipt.status == .needsReview { return .pendingReview }
return .scanned
}
var label: String {
switch self {
case .scanned: return "Erfasst"
case .pendingReview: return "Prüfung erforderlich"
case .confirmed: return "Bestätigt"
}
}
var systemImage: String {
switch self {
case .scanned: return "checkmark.circle.fill"
case .pendingReview: return "exclamationmark.triangle.fill"
case .confirmed: return "person.fill.checkmark"
}
}
var background: Color {
switch self {
case .scanned: return .zenithScannedBg
case .pendingReview: return .zenithPendingBg
case .confirmed: return .zenithConfirmedBg
}
}
var foreground: Color {
switch self {
case .scanned: return .zenithScannedText
case .pendingReview: return .zenithPendingText
case .confirmed: return .zenithConfirmedText
}
}
var border: Color {
switch self {
case .scanned: return .zenithScannedBorder
case .pendingReview: return .zenithPendingBorder
case .confirmed: return .zenithConfirmedBorder
}
}
var dot: Color {
switch self {
case .scanned: return .zenithScannedDot
case .pendingReview: return .zenithPendingDot
case .confirmed: return .zenithConfirmedDot
}
}
}
/// The badge itself small rectangular (0-radius), bordered, dot + icon +
/// label, exactly the shape `StatusBadge.tsx` renders on the web.
struct ZenithStatusBadge: View {
let tier: ReceiptStatusTier
var showIcon = true
var showDot = true
init(_ receipt: Receipt, showIcon: Bool = true, showDot: Bool = true) {
self.tier = .resolve(receipt)
self.showIcon = showIcon
self.showDot = showDot
}
init(tier: ReceiptStatusTier, showIcon: Bool = true, showDot: Bool = true) {
self.tier = tier
self.showIcon = showIcon
self.showDot = showDot
}
var body: some View {
HStack(spacing: 6) {
if showDot {
Circle().fill(tier.dot).frame(width: 6, height: 6)
}
if showIcon {
Image(systemName: tier.systemImage)
.font(.system(size: 10, weight: .semibold))
}
Text(tier.label)
.lineLimit(1)
}
.font(.zenithLabelSm)
.foregroundStyle(tier.foreground)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(tier.background)
.overlay(Rectangle().strokeBorder(tier.border, lineWidth: 1))
}
}
/// A generic (non-status) rectangular chip `DESIGN (1).md` Components
/// Chips/Tags: "`label-md` typography, small rectangular boxes with #F5F7F9
/// fills and no borders." Use for categories, payment methods, etc.
struct ZenithChip: View {
let text: String
var body: some View {
Text(text)
.zenithLabelMdStyle(color: .zenithMuted)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.zenithLow)
}
}

View File

@@ -0,0 +1,79 @@
import SwiftUI
/// Input fields per `DESIGN (1).md` Components Input Fields: "1px solid
/// #E2E8F0 bottom-border only... Use `label-caps` for field labels placed
/// strictly above the input." Border thickens to 2px while focused the
/// same "shift, don't lift" interaction rule as `ZenithButtonStyle`.
///
/// Use this instead of a bare `TextField` + `.textFieldStyle(.roundedBorder)`
/// everywhere in the app; `.roundedBorder` fights this design system
/// directly (it's rounded and iOS-chrome-grey, the opposite of "sharp,
/// monochrome, architectural").
struct ZenithTextField: View {
let label: String
@Binding var text: String
var placeholder: String = ""
var isSecure = false
var keyboardType: UIKeyboardType = .default
var textContentType: UITextContentType?
var autocapitalization: TextInputAutocapitalization = .sentences
var autocorrectionDisabled = false
@FocusState private var isFocused: Bool
var body: some View {
VStack(alignment: .leading, spacing: ZenithSpacing.unit) {
Text(label).zenithLabelCapsStyle()
Group {
if isSecure {
SecureField(placeholder, text: $text)
} else {
TextField(placeholder, text: $text)
}
}
.font(.zenithBodyMd)
.foregroundStyle(.zenithText)
.keyboardType(keyboardType)
.textContentType(textContentType)
.textInputAutocapitalization(autocapitalization)
.autocorrectionDisabled(autocorrectionDisabled)
.focused($isFocused)
.padding(.vertical, ZenithSpacing.unit * 2)
.overlay(alignment: .bottom) {
Rectangle()
.fill(isFocused ? Color.zenithBlack : Color.zenithBorder)
.frame(height: isFocused ? 2 : 1)
}
}
}
}
/// A numeric variant bound to an optional `Double`, for amount fields free
/// text with comma-or-dot decimal entry (matches how receipts are actually
/// typed in German usage) rather than a strict `TextField(value:format:)`
/// that rejects a comma outright.
struct ZenithAmountField: View {
let label: String
@Binding var value: Double?
var placeholder: String = "0,00"
private var textProxy: Binding<String> {
Binding(
get: { value.map { String(format: "%.2f", $0) } ?? "" },
set: { newValue in
let normalized = newValue.replacingOccurrences(of: ",", with: ".")
value = Double(normalized)
}
)
}
var body: some View {
ZenithTextField(
label: label,
text: textProxy,
placeholder: placeholder,
keyboardType: .decimalPad
)
}
}

View File

@@ -0,0 +1,93 @@
import SwiftUI
/// The web app's three-font type system (see `DESIGN (1).md` Typography,
/// and `tailwind.config.ts`'s `fontFamily`), ported with the SAME font
/// files: `Resources/Fonts/*.ttf` are the real Hanken Grotesk / Inter /
/// JetBrains Mono variable fonts (downloaded from the canonical
/// google/fonts repository the same open-source files the web app loads
/// from Google Fonts' CDN), registered via `UIAppFonts` in `project.yml`.
///
/// PostScript names below were read directly out of each font file's `name`
/// table (not guessed) see the font files themselves if these ever need
/// re-verifying after a font update.
///
/// - Hanken Grotesk headlines, set tight (negative tracking) at large
/// sizes for the "locked, architectural" feel the design system asks for.
/// - Inter body copy.
/// - JetBrains Mono labels, captions, technical/numeric data. `labelCaps`
/// is additionally uppercased with wide tracking apply
/// `.zenithLabelCapsStyle()`, not just the bare font, or the case/tracking
/// won't happen (SwiftUI doesn't derive that from the font itself).
enum ZenithFont {
static let display = "HankenGrotesk-Regular"
static let body = "Inter-Regular"
static let mono = "JetBrainsMono-Regular"
}
extension Font {
/// Rarely needed on iPhone (72pt) kept for completeness / a future
/// iPad or marketing surface.
static var zenithDisplayLg: Font { .custom(ZenithFont.display, size: 72).weight(.bold) }
/// Screen/section titles. Sized at the web system's "headline-lg-mobile"
/// (32/40) rather than the 48pt desktop size, since this app is
/// iPhone-only (see project.yml's TARGETED_DEVICE_FAMILY).
static var zenithHeadlineLg: Font { .custom(ZenithFont.display, size: 32).weight(.semibold) }
/// Card/sub-section titles.
static var zenithHeadlineMd: Font { .custom(ZenithFont.display, size: 24).weight(.medium) }
static var zenithBodyLg: Font { .custom(ZenithFont.body, size: 18) }
/// Default body/UI text size.
static var zenithBodyMd: Font { .custom(ZenithFont.body, size: 16) }
static var zenithBodySm: Font { .custom(ZenithFont.body, size: 14) }
/// Field labels, eyebrows, section headers use with
/// `.zenithLabelCapsStyle()` for the uppercase + tracking, not bare.
static var zenithLabelCaps: Font { .custom(ZenithFont.mono, size: 12).weight(.medium) }
/// Chips, technical/tabular data (amounts, dates, IDs).
static var zenithLabelMd: Font { .custom(ZenithFont.mono, size: 14) }
static var zenithLabelSm: Font { .custom(ZenithFont.mono, size: 12) }
}
/// Complete text styles (font + tracking + default color) for each type
/// role. Tracking values are the design system's em values
/// (`DESIGN (1).md` Typography) converted to points AT EACH ROLE'S OWN
/// size `SwiftUI.tracking(_:)` takes absolute points, not em, so these
/// can't be a single generic modifier parameterized only by a ratio.
/// Prefer these over composing `.font(.zenith...)` by hand.
extension View {
/// Screen/section titles (32pt, -0.02em -0.64pt tracking).
func zenithHeadlineLgStyle(color: Color = .zenithText) -> some View {
font(.zenithHeadlineLg).tracking(-0.64).foregroundStyle(color)
}
/// Card/sub-section titles (24pt, -0.01em -0.24pt tracking).
func zenithHeadlineMdStyle(color: Color = .zenithText) -> some View {
font(.zenithHeadlineMd).tracking(-0.24).foregroundStyle(color)
}
/// Default body text (16pt, 0 tracking).
func zenithBodyStyle(color: Color = .zenithText) -> some View {
font(.zenithBodyMd).foregroundStyle(color)
}
func zenithBodySmStyle(color: Color = .zenithMuted) -> some View {
font(.zenithBodySm).foregroundStyle(color)
}
/// `label-caps` role: JetBrains Mono, uppercase, wide tracking
/// (12pt, 0.1em 1.2pt). This is the style used above every
/// `ZenithTextField` and for chip/badge/eyebrow text.
func zenithLabelCapsStyle(color: Color = .zenithSubtle) -> some View {
font(.zenithLabelCaps)
.textCase(.uppercase)
.tracking(1.2)
.foregroundStyle(color)
}
/// Technical/tabular data amounts, dates, IDs (14pt, 0 tracking).
func zenithLabelMdStyle(color: Color = .zenithText) -> some View {
font(.zenithLabelMd).foregroundStyle(color)
}
}

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)
}
}

View File

@@ -0,0 +1,99 @@
import SwiftUI
/// Presented from `ReceiptsListView`'s toolbar. Exports the given receipts as
/// CSV, XLSX, or PDF via `ExportAPI`, writes the result to a temp file (so
/// the receiving app sees the right filename/extension), and hands it to the
/// iOS share sheet.
struct ExportSheet: View {
let receipts: [Receipt]
@Environment(\.dismiss) private var dismiss
@State private var isExporting = false
@State private var errorMessage: String?
@State private var shareFile: ShareFile?
var body: some View {
NavigationStack {
VStack(spacing: ZenithSpacing.sm) {
if receipts.isEmpty {
Text("Keine Belege zum Exportieren.")
.zenithBodyStyle(color: .zenithMuted)
.padding(.top, 40)
} else {
Text("\(receipts.count) Beleg\(receipts.count == 1 ? "" : "e") exportieren")
.zenithHeadlineMdStyle()
.padding(.top, ZenithSpacing.md)
VStack(spacing: ZenithSpacing.xs) {
exportButton(title: "Als CSV exportieren", systemImage: "tablecells", format: .csv)
exportButton(title: "Als Excel exportieren", systemImage: "tablecells.fill", format: .excel)
exportButton(title: "Als PDF exportieren", systemImage: "doc.richtext", format: .pdf)
}
.padding(.horizontal, ZenithSpacing.sm)
}
if isExporting {
ProgressView()
}
if let errorMessage {
Text(errorMessage)
.zenithBodySmStyle(color: .zenithError)
.multilineTextAlignment(.center)
.padding(.horizontal, ZenithSpacing.sm)
}
Spacer()
}
.background(Color.zenithBg.ignoresSafeArea())
.navigationTitle("Exportieren")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Fertig") { dismiss() }
.buttonStyle(.zenithPlain)
}
}
.sheet(item: $shareFile) { file in
ShareSheet(activityItems: [file.url])
}
}
}
private func exportButton(title: String, systemImage: String, format: ExportAPI.Format) -> some View {
Button {
Task { await export(format) }
} label: {
Label(title, systemImage: systemImage)
.frame(maxWidth: .infinity)
}
.buttonStyle(.zenithPrimary)
.disabled(isExporting)
}
private func export(_ format: ExportAPI.Format) async {
isExporting = true
errorMessage = nil
defer { isExporting = false }
do {
let result = try await ExportAPI.export(format, receipts: receipts, locale: "de")
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(result.fileName)
try result.data.write(to: tempURL, options: .atomic)
shareFile = ShareFile(url: tempURL)
} catch let apiError as APIError {
if case .server(let code, _, _) = apiError, code == "pro_required" {
errorMessage = "Export ist Pro-Nutzern vorbehalten."
} else {
errorMessage = apiError.localizedDescription
}
} catch {
errorMessage = error.localizedDescription
}
}
}
/// Wraps a temp-file URL so it can drive `.sheet(item:)`.
private struct ShareFile: Identifiable {
let url: URL
var id: String { url.absoluteString }
}

View File

@@ -0,0 +1,507 @@
import SwiftUI
import UIKit
/// Detail/edit screen for a single receipt, pushed from `ReceiptsListView`.
/// Edits a local `draft` copy; "Speichern" pushes it via
/// `ReceiptsAPI.sync([draft])`, "Löschen" confirms then calls
/// `ReceiptsAPI.delete(id:)` and pops back on success.
///
/// The body is deliberately split into small `@ViewBuilder` sections rather
/// than one large `Form { ... }` a single body this size (many Sections,
/// Pickers, and conditionals) can make the Swift type-checker choke, and
/// there is no compiler available in this environment to catch that.
struct ReceiptDetailView: View {
@State private var draft: Receipt
@Environment(\.dismiss) private var dismiss
@State private var isSaving = false
@State private var didSave = false
@State private var saveErrorMessage: String?
@State private var isDeleting = false
@State private var showDeleteConfirmation = false
@State private var deleteErrorMessage: String?
init(receipt: Receipt) {
_draft = State(initialValue: receipt)
}
var body: some View {
Form {
imageSection
merchantSection
receiptSection
amountSection
categorySection
paymentMethodSection
lineItemsSection
notesSection
infoSection
actionsSection
}
.zenithListBackground()
.navigationTitle(draft.merchant.name.isEmpty ? "Beleg" : draft.merchant.name)
.navigationBarTitleDisplayMode(.inline)
.task {
await loadPreviewIfNeeded()
}
.alert(
"Beleg löschen?",
isPresented: $showDeleteConfirmation
) {
Button("Abbrechen", role: .cancel) {}
Button("Löschen", role: .destructive) {
Task { await performDelete() }
}
} message: {
Text("Diese Aktion kann nicht rückgängig gemacht werden.")
}
.alert(
"Löschen fehlgeschlagen",
isPresented: Binding(
get: { deleteErrorMessage != nil },
set: { isPresented in
if !isPresented { deleteErrorMessage = nil }
}
)
) {
Button("OK", role: .cancel) { deleteErrorMessage = nil }
} message: {
Text(deleteErrorMessage ?? "")
}
}
// MARK: - Sections
@ViewBuilder
private var imageSection: some View {
if let uiImage = dataURLImage {
Section {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.frame(maxWidth: .infinity)
.overlay(Rectangle().strokeBorder(Color.zenithBorder, lineWidth: 1))
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
} else if let remoteURL = remoteImageURL {
Section {
remoteImageView(url: remoteURL)
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
}
}
private func remoteImageView(url: URL) -> some View {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.frame(maxWidth: .infinity)
.overlay(Rectangle().strokeBorder(Color.zenithBorder, lineWidth: 1))
case .empty:
ProgressView()
.frame(maxWidth: .infinity, minHeight: 120)
case .failure:
EmptyView()
@unknown default:
EmptyView()
}
}
}
private var merchantSection: some View {
Section {
ZenithTextField(label: "Name", text: $draft.merchant.name)
ZenithTextField(label: "Adresse", text: addressBinding)
ZenithTextField(label: "Steuer-ID", text: taxIdBinding)
} header: {
Text("Händler").zenithLabelCapsStyle()
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
}
private var receiptSection: some View {
Section {
DatePicker("Datum", selection: dateBinding, displayedComponents: .date)
.zenithBodyStyle()
ZenithTextField(label: "Belegnummer", text: receiptNumberBinding)
Picker("Belegart", selection: $draft.documentType) {
ForEach(Receipt.DocumentType.allCases, id: \.self) { type in
Text(type.displayLabel).tag(type)
}
}
.zenithBodyStyle()
} header: {
Text("Beleg").zenithLabelCapsStyle()
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
}
private var amountSection: some View {
Section {
ZenithAmountField(label: "Gesamtbetrag", value: totalAmountBinding)
ZenithAmountField(label: "Netto", value: $draft.netAmount)
ZenithAmountField(label: "Trinkgeld", value: $draft.tipAmount)
ZenithTextField(label: "Währung", text: $draft.currency, autocorrectionDisabled: true)
HStack {
Text("Gesamt inkl. Trinkgeld").zenithBodyStyle()
Spacer()
Text(draft.grossWithTip.formatted(.currency(code: currencyCodeOrFallback)))
.zenithLabelMdStyle()
}
} header: {
Text("Betrag").zenithLabelCapsStyle()
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
}
private var categorySection: some View {
Section {
Picker("Kategorie", selection: $draft.suggestedCategory) {
ForEach(Receipt.ReceiptCategory.allCases, id: \.self) { category in
Text(category.rawValue).tag(category)
}
}
.zenithBodyStyle()
} header: {
Text("Kategorie").zenithLabelCapsStyle()
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
}
private var paymentMethodSection: some View {
Section {
Picker("Zahlungsart", selection: $draft.paymentMethod) {
Text("Keine Angabe").tag(Receipt.PaymentMethod?.none)
ForEach(Receipt.PaymentMethod.allCases, id: \.self) { method in
Text(method.displayLabel).tag(Receipt.PaymentMethod?.some(method))
}
}
.zenithBodyStyle()
} header: {
Text("Zahlungsart").zenithLabelCapsStyle()
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
}
private var lineItemsSection: some View {
Section {
ForEach($draft.lineItems) { $item in
lineItemRow(item: $item)
}
.onDelete { offsets in
draft.lineItems.remove(atOffsets: offsets)
}
Button {
draft.lineItems.append(Receipt.LineItem(description: "", quantity: 1, price: 0))
} label: {
Label("Position hinzufügen", systemImage: "plus")
}
.buttonStyle(.zenithPlain)
} header: {
Text("Positionen").zenithLabelCapsStyle()
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
}
private func lineItemRow(item: Binding<Receipt.LineItem>) -> some View {
VStack(alignment: .leading, spacing: ZenithSpacing.xs) {
ZenithTextField(label: "Beschreibung", text: item.description)
HStack(spacing: ZenithSpacing.xs) {
TextField("Menge", value: item.quantity, format: .number)
.keyboardType(.decimalPad)
.frame(maxWidth: 70)
.zenithLabelMdStyle()
Text("×")
.zenithBodySmStyle()
TextField("Preis", value: item.price, format: .number)
.keyboardType(.decimalPad)
.zenithLabelMdStyle()
}
}
.padding(.vertical, ZenithSpacing.unit)
}
private var notesSection: some View {
Section {
ZenithTextField(label: "Anlass", text: occasionBinding)
ZenithTextField(label: "Teilnehmer", text: participantsBinding)
} header: {
Text("Notizen (Bewirtung)").zenithLabelCapsStyle()
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
}
@ViewBuilder
private var infoSection: some View {
Section {
if let created = ISO8601.parse(draft.createdAt) {
HStack {
Text("Erstellt").zenithBodyStyle()
Spacer()
Text(Self.dateTimeFormatter.string(from: created)).zenithLabelMdStyle()
}
}
if let updated = ISO8601.parse(draft.updatedAt) {
HStack {
Text("Aktualisiert").zenithBodyStyle()
Spacer()
Text(Self.dateTimeFormatter.string(from: updated)).zenithLabelMdStyle()
}
}
if draft.validation.needsUserReview {
Label(draft.validation.reviewReason ?? "Bitte prüfen.", systemImage: "exclamationmark.triangle.fill")
.font(.zenithBodySm)
.foregroundStyle(Color.zenithPendingText)
.padding(ZenithSpacing.xs)
.background(Color.zenithPendingBg)
.overlay(Rectangle().strokeBorder(Color.zenithPendingBorder, lineWidth: 1))
}
} header: {
Text("Info").zenithLabelCapsStyle()
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
}
@ViewBuilder
private var actionsSection: some View {
Section {
Button {
Task { await save() }
} label: {
if isSaving {
ProgressView().tint(.white)
} else {
Text("Speichern")
}
}
.buttonStyle(.zenithPrimary)
.disabled(isSaving)
if didSave && saveErrorMessage == nil {
HStack {
Spacer()
Label("Gespeichert.", systemImage: "checkmark.circle")
.foregroundStyle(Color.zenithScannedText)
Spacer()
}
}
if let saveErrorMessage {
Text(saveErrorMessage)
.zenithBodySmStyle(color: .zenithError)
}
Button {
showDeleteConfirmation = true
} label: {
if isDeleting {
ProgressView()
} else {
Text("Löschen")
}
}
.buttonStyle(.zenithSecondaryDestructive)
.disabled(isDeleting)
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
}
// MARK: - Image
/// `previewUrl` may be a `data:` URL (from `includePreview=1`) or an
/// http(s) URL. This handles the `data:` case; `remoteImageURL` handles
/// the other.
private var dataURLImage: UIImage? {
guard let previewUrl = draft.previewUrl, previewUrl.hasPrefix("data:") else { return nil }
guard let commaIndex = previewUrl.firstIndex(of: ",") else { return nil }
let base64 = String(previewUrl[previewUrl.index(after: commaIndex)...])
guard let data = Data(base64Encoded: base64) else { return nil }
return UIImage(data: data)
}
private var remoteImageURL: URL? {
guard let previewUrl = draft.previewUrl,
previewUrl.hasPrefix("http://") || previewUrl.hasPrefix("https://") else { return nil }
return URL(string: previewUrl)
}
/// `ReceiptsAPI.list()` (used by the list screen) is called without
/// `includePreview`, so `previewUrl` is typically nil at this point.
/// Fetch the single receipt with its preview inlined rather than showing
/// a blank image area. Only touches `previewUrl`, so it can't clobber
/// any in-progress edit elsewhere on the draft.
private func loadPreviewIfNeeded() async {
guard draft.previewUrl == nil else { return }
if let full = try? await ReceiptsAPI.get(id: draft.id) {
draft.previewUrl = full.previewUrl
}
}
// MARK: - Bindings for optional / nested fields
private var addressBinding: Binding<String> {
Binding(
get: { draft.merchant.address ?? "" },
set: { draft.merchant.address = $0.isEmpty ? nil : $0 }
)
}
private var taxIdBinding: Binding<String> {
Binding(
get: { draft.merchant.taxId ?? "" },
set: { draft.merchant.taxId = $0.isEmpty ? nil : $0 }
)
}
private var receiptNumberBinding: Binding<String> {
Binding(
get: { draft.receiptNumber ?? "" },
set: { draft.receiptNumber = $0.isEmpty ? nil : $0 }
)
}
/// `draft.totalAmount.value` is a required `Double`, but `ZenithAmountField`
/// takes `Binding<Double?>` this small proxy adapts it. Mirrors the same
/// pattern in `ReceiptReviewView.totalAmountBinding`: a momentarily empty
/// field (while retyping) is ignored rather than zeroing out the total.
private var totalAmountBinding: Binding<Double?> {
Binding<Double?>(
get: { draft.totalAmount.value },
set: { newValue in
if let newValue { draft.totalAmount.value = newValue }
}
)
}
private var occasionBinding: Binding<String> {
Binding(
get: { draft.hospitality?.occasion ?? "" },
set: { newValue in
if draft.hospitality == nil {
draft.hospitality = Receipt.Hospitality(occasion: nil, participants: nil)
}
draft.hospitality?.occasion = newValue.isEmpty ? nil : newValue
}
)
}
private var participantsBinding: Binding<String> {
Binding(
get: { draft.hospitality?.participants ?? "" },
set: { newValue in
if draft.hospitality == nil {
draft.hospitality = Receipt.Hospitality(occasion: nil, participants: nil)
}
draft.hospitality?.participants = newValue.isEmpty ? nil : newValue
}
)
}
/// `draft.date.isoDate` is a plain "YYYY-MM-DD" string (no time/zone
/// component) converted through a UTC-anchored formatter on both sides
/// so round-tripping through `DatePicker` can't shift the calendar day.
private var dateBinding: Binding<Date> {
Binding(
get: { Self.isoDateOnlyFormatter.date(from: draft.date.isoDate) ?? Date() },
set: { draft.date.isoDate = Self.isoDateOnlyFormatter.string(from: $0) }
)
}
private var currencyCodeOrFallback: String {
draft.currency.isEmpty ? "EUR" : draft.currency
}
// MARK: - Actions
private func save() async {
isSaving = true
saveErrorMessage = nil
didSave = false
defer { isSaving = false }
do {
try await ReceiptsAPI.sync([draft])
didSave = true
} catch {
saveErrorMessage = error.localizedDescription
}
}
private func performDelete() async {
isDeleting = true
deleteErrorMessage = nil
defer { isDeleting = false }
do {
try await ReceiptsAPI.delete(id: draft.id)
dismiss()
} catch {
deleteErrorMessage = error.localizedDescription
}
}
// MARK: - Formatting helpers
private static let isoDateOnlyFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "UTC")
return formatter
}()
private static let dateTimeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
formatter.locale = Locale(identifier: "de_DE")
return formatter
}()
}
// MARK: - German display labels
private extension Receipt.DocumentType {
var displayLabel: String {
switch self {
case .kassenbon: return "Kassenbon"
case .rechnung: return "Rechnung"
case .tankbeleg: return "Tankbeleg"
case .bewirtungsbeleg: return "Bewirtungsbeleg"
case .parkticket: return "Parkticket"
case .sonstiges: return "Sonstiges"
}
}
}
private extension Receipt.PaymentMethod {
var displayLabel: String {
switch self {
case .bar: return "Bar"
case .ecKarte: return "EC-Karte"
case .kreditkarte: return "Kreditkarte"
case .ueberweisung: return "Überweisung"
case .paypal: return "PayPal"
case .applePay: return "Apple Pay"
case .googlePay: return "Google Pay"
case .sonstige: return "Sonstige"
}
}
}

View File

@@ -0,0 +1,72 @@
import SwiftUI
/// One row in `ReceiptsListView`: merchant, formatted business date and
/// amount, category, and the shared `ZenithStatusBadge` (matching the web
/// dashboard's `StatusBadge.tsx`).
struct ReceiptRow: View {
let receipt: Receipt
var body: some View {
HStack(alignment: .top, spacing: ZenithSpacing.sm) {
VStack(alignment: .leading, spacing: ZenithSpacing.unit) {
Text(receipt.merchant.name.isEmpty ? "Unbekannter Händler" : receipt.merchant.name)
.zenithBodyStyle()
.fontWeight(.medium)
.lineLimit(1)
HStack(spacing: 6) {
Text(formattedDate)
Text("·")
Text(receipt.suggestedCategory.rawValue)
}
.zenithBodySmStyle()
.lineLimit(1)
}
Spacer(minLength: ZenithSpacing.xs)
VStack(alignment: .trailing, spacing: 6) {
Text(formattedAmount)
.zenithLabelMdStyle()
.fontWeight(.medium)
.multilineTextAlignment(.trailing)
ZenithStatusBadge(receipt)
.accessibilityElement(children: .combine)
}
}
.padding(.vertical, ZenithSpacing.xs)
}
// `receipt.date.isoDate` is a plain "YYYY-MM-DD" string (no time/zone
// component), so it deliberately does NOT go through ISO8601.parse
// (which expects a full timestamp and would just return nil here).
private var formattedDate: String {
guard let date = Self.isoDateOnlyFormatter.date(from: receipt.date.isoDate) else {
return receipt.date.isoDate
}
return Self.displayDateFormatter.string(from: date)
}
private var formattedAmount: String {
let code = receipt.currency.isEmpty ? "EUR" : receipt.currency
return receipt.totalAmount.value.formatted(.currency(code: code))
}
private static let isoDateOnlyFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "UTC")
return formatter
}()
private static let displayDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
formatter.locale = Locale(identifier: "de_DE")
formatter.timeZone = TimeZone(identifier: "UTC")
return formatter
}()
}

View File

@@ -0,0 +1,132 @@
import SwiftUI
/// The "Belege" tab root (see `App/MainTabView.swift`, which references this
/// type by a zero-arg init). Loads the signed-in user's receipts, supports
/// client-side search by merchant name, swipe-to-delete, and exporting the
/// currently visible receipts via `ExportSheet`. Owns its own
/// `NavigationStack`.
struct ReceiptsListView: View {
@State private var receipts: [Receipt] = []
@State private var searchText = ""
@State private var isLoading = false
@State private var loadErrorMessage: String?
@State private var deleteErrorMessage: String?
@State private var showExportSheet = false
var body: some View {
NavigationStack {
Group {
if receipts.isEmpty && !isLoading && loadErrorMessage == nil {
ContentUnavailableView(
"Noch keine Belege gescannt.",
systemImage: "list.bullet.rectangle"
)
.foregroundStyle(Color.zenithMuted)
} else {
receiptsList
}
}
.background(Color.zenithBg.ignoresSafeArea())
.navigationTitle("Belege")
.searchable(text: $searchText, prompt: "Suchen")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showExportSheet = true
} label: {
Label("Exportieren", systemImage: "square.and.arrow.up")
}
.disabled(filteredReceipts.isEmpty)
}
}
.task {
await load()
}
.refreshable {
await load()
}
.sheet(isPresented: $showExportSheet) {
ExportSheet(receipts: filteredReceipts)
}
.alert(
"Löschen fehlgeschlagen",
isPresented: Binding(
get: { deleteErrorMessage != nil },
set: { isPresented in
if !isPresented { deleteErrorMessage = nil }
}
)
) {
Button("OK", role: .cancel) { deleteErrorMessage = nil }
} message: {
Text(deleteErrorMessage ?? "")
}
}
}
/// Client-side filter by merchant name this is also what gets exported
/// via the toolbar button, so exporting after a search exports only the
/// filtered set.
private var filteredReceipts: [Receipt] {
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return receipts }
return receipts.filter { $0.merchant.name.localizedCaseInsensitiveContains(trimmed) }
}
private var receiptsList: some View {
List {
if let loadErrorMessage {
Section {
Text(loadErrorMessage)
.zenithBodySmStyle(color: .zenithError)
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
}
ForEach(filteredReceipts) { receipt in
NavigationLink {
ReceiptDetailView(receipt: receipt)
} label: {
ReceiptRow(receipt: receipt)
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await delete(receipt) }
} label: {
Label("Löschen", systemImage: "trash")
}
}
}
}
.listStyle(.plain)
.zenithListBackground()
}
private func load() async {
isLoading = true
loadErrorMessage = nil
defer { isLoading = false }
do {
receipts = try await ReceiptsAPI.list()
} catch {
loadErrorMessage = error.localizedDescription
}
}
/// Optimistic delete: removes the row immediately, then confirms with the
/// server. On failure the row is reinserted at its original position and
/// an error alert is shown.
private func delete(_ receipt: Receipt) async {
guard let index = receipts.firstIndex(where: { $0.id == receipt.id }) else { return }
let removed = receipts.remove(at: index)
do {
try await ReceiptsAPI.delete(id: removed.id)
} catch {
receipts.insert(removed, at: min(index, receipts.count))
deleteErrorMessage = error.localizedDescription
}
}
}

View File

@@ -0,0 +1,20 @@
import SwiftUI
import UIKit
/// Thin `UIViewControllerRepresentable` wrapper around `UIActivityViewController`,
/// used by `ExportSheet` to hand an exported file off to Mail, Files,
/// AirDrop, etc.
struct ShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
var excludedActivityTypes: [UIActivity.ActivityType]?
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
controller.excludedActivityTypes = excludedActivityTypes
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
// Nothing to update the activity items are fixed for the sheet's lifetime.
}
}

View File

@@ -0,0 +1,65 @@
import SwiftUI
import VisionKit
/// Wraps `VNDocumentCameraViewController` (the system document-scanning
/// camera UI) for SwiftUI. Only the first scanned page is used for v1 good
/// enough for a single-receipt photo, and the backend already accepts a
/// single image per `/api/scan` call.
///
/// IMPORTANT: `VNDocumentCameraViewController.isSupported` is `false` on the
/// Simulator and on devices without a usable camera. Callers must check that
/// before presenting this view and fall back to the photo picker instead
/// this type does not check it itself.
struct DocumentCameraView: UIViewControllerRepresentable {
/// Called exactly once with the captured page, or `nil` if the user
/// cancelled or the scan failed. Never left uncalled, so the caller never
/// hangs waiting on a result.
var onComplete: (UIImage?) -> Void
func makeUIViewController(context: Context) -> VNDocumentCameraViewController {
let controller = VNDocumentCameraViewController()
controller.delegate = context.coordinator
return controller
}
func updateUIViewController(_ uiViewController: VNDocumentCameraViewController, context: Context) {
// No dynamic updates needed.
}
func makeCoordinator() -> Coordinator {
Coordinator(onComplete: onComplete)
}
final class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate {
private let onComplete: (UIImage?) -> Void
init(onComplete: @escaping (UIImage?) -> Void) {
self.onComplete = onComplete
}
func documentCameraViewController(
_ controller: VNDocumentCameraViewController,
didFinishWith scan: VNDocumentCameraScan
) {
let image = scan.pageCount > 0 ? scan.imageOfPage(at: 0) : nil
controller.dismiss(animated: true) {
self.onComplete(image)
}
}
func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {
controller.dismiss(animated: true) {
self.onComplete(nil)
}
}
func documentCameraViewController(
_ controller: VNDocumentCameraViewController,
didFailWithError error: Error
) {
controller.dismiss(animated: true) {
self.onComplete(nil)
}
}
}
}

View File

@@ -0,0 +1,253 @@
import Foundation
import SwiftUI
/// Shown after a successful `/api/scan` call. Lets the user review/correct
/// the AI-extracted fields before persisting the receipt via
/// `ReceiptsAPI.sync(...)`. Edits are kept in a local `@State` copy so a
/// failed save never loses what the user typed.
struct ReceiptReviewView: View {
@State private var receipt: Receipt
@State private var isSaving = false
@State private var saveErrorMessage: String?
@Environment(\.dismiss) private var dismiss
init(receipt: Receipt) {
_receipt = State(initialValue: receipt)
}
var body: some View {
ZStack {
Color.zenithBg.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: ZenithSpacing.md) {
ZenithStatusBadge(receipt)
// MARK: Belegdaten (Händler / Datum / Betrag)
VStack(alignment: .leading, spacing: ZenithSpacing.sm) {
HStack(alignment: .bottom, spacing: ZenithSpacing.xs) {
ZenithTextField(label: "Händlername", text: $receipt.merchant.name)
confidenceIndicator(receipt.merchant.confidence)
}
ZenithDivider()
HStack(alignment: .bottom, spacing: ZenithSpacing.xs) {
LabeledFieldShell(label: "Datum") {
DatePicker("", selection: dateBinding, displayedComponents: [.date])
.labelsHidden()
.datePickerStyle(.compact)
.tint(.zenithBlack)
}
confidenceIndicator(receipt.date.confidence)
}
ZenithDivider()
HStack(alignment: .bottom, spacing: ZenithSpacing.xs) {
ZenithAmountField(label: "Betrag", value: totalAmountBinding)
Text(receipt.currency)
.zenithLabelMdStyle(color: .zenithMuted)
.padding(.bottom, ZenithSpacing.unit * 2)
confidenceIndicator(receipt.totalAmount.confidence)
}
}
.zenithCard()
// MARK: Kategorie / Zahlungsart
VStack(alignment: .leading, spacing: ZenithSpacing.sm) {
LabeledFieldShell(label: "Kategorie") {
Menu {
ForEach(Receipt.ReceiptCategory.allCases, id: \.self) { category in
Button(category.rawValue) { receipt.suggestedCategory = category }
}
} label: {
menuLabel(receipt.suggestedCategory.rawValue)
}
}
ZenithDivider()
LabeledFieldShell(label: "Zahlungsart") {
Menu {
Button("Keine Angabe") { receipt.paymentMethod = nil }
ForEach(Receipt.PaymentMethod.allCases, id: \.self) { method in
Button(method.rawValue) { receipt.paymentMethod = method }
}
} label: {
menuLabel(receipt.paymentMethod?.rawValue ?? "Keine Angabe")
}
}
}
.zenithCard()
// MARK: Positionen
VStack(alignment: .leading, spacing: ZenithSpacing.sm) {
Text("Positionen").zenithLabelCapsStyle()
ForEach(receipt.lineItems.indices, id: \.self) { index in
if index > 0 { ZenithDivider() }
HStack(spacing: ZenithSpacing.xs) {
TextField("Beschreibung", text: $receipt.lineItems[index].description)
.font(.zenithBodyMd)
.foregroundStyle(.zenithText)
Spacer(minLength: ZenithSpacing.unit)
TextField("Preis", value: $receipt.lineItems[index].price, format: .number)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 72)
.zenithLabelMdStyle()
Button {
receipt.lineItems.remove(at: index)
} label: {
Image(systemName: "xmark.circle")
.foregroundStyle(.zenithSubtle)
}
}
.padding(.vertical, ZenithSpacing.unit)
}
Button {
receipt.lineItems.append(Receipt.LineItem(description: "", quantity: 1, price: 0))
} label: {
Label("Position hinzufügen", systemImage: "plus")
}
.buttonStyle(.zenithPlain)
.padding(.top, ZenithSpacing.unit)
}
.zenithCard()
// MARK: Save
Button {
Task { await save() }
} label: {
if isSaving {
ProgressView()
.tint(.white)
} else {
Text("Speichern")
}
}
.buttonStyle(.zenithPrimary)
.disabled(isSaving)
if let saveErrorMessage {
Text(saveErrorMessage)
.zenithBodySmStyle(color: .zenithError)
}
}
.padding(ZenithSpacing.sm)
}
}
.navigationTitle("Beleg prüfen")
.navigationBarTitleDisplayMode(.inline)
}
// MARK: - Confidence indicator
/// Small colored dot + percentage, shown only for fields the backend
/// flagged as low-confidence (< 70%) mirrors what the web dashboard
/// highlights for manual review.
@ViewBuilder
private func confidenceIndicator(_ confidence: Double) -> some View {
if confidence < 0.7 {
HStack(spacing: 4) {
Circle()
.fill(Color.zenithPendingDot)
.frame(width: 8, height: 8)
Text("\(Int((confidence * 100).rounded()))%")
.zenithLabelMdStyle(color: .zenithPendingText)
}
.padding(.bottom, ZenithSpacing.unit * 2)
}
}
/// Text + chevron shown as a `Menu`'s label, styled to match
/// `ZenithTextField`'s body text instead of the system default.
private func menuLabel(_ text: String) -> some View {
HStack {
Text(text).zenithBodyStyle()
Spacer()
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.zenithSubtle)
}
}
// MARK: - Date <-> String("yyyy-MM-dd") binding
private static let isoDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "UTC")
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
private var dateBinding: Binding<Date> {
Binding<Date>(
get: { Self.isoDateFormatter.date(from: receipt.date.isoDate) ?? Date() },
set: { newValue in receipt.date.isoDate = Self.isoDateFormatter.string(from: newValue) }
)
}
// MARK: - Total amount Double <-> Double? binding (for ZenithAmountField)
private var totalAmountBinding: Binding<Double?> {
Binding<Double?>(
get: { receipt.totalAmount.value },
set: { newValue in
if let newValue { receipt.totalAmount.value = newValue }
}
)
}
// MARK: - Save
private func save() async {
isSaving = true
saveErrorMessage = nil
defer { isSaving = false }
do {
try await ReceiptsAPI.sync([receipt])
dismiss()
} catch let error as APIError {
saveErrorMessage = error.localizedDescription
} catch {
saveErrorMessage = error.localizedDescription
}
}
}
/// Local layout helper: reproduces `ZenithTextField`'s "label-caps text
/// above a hairline-bottom-border field" shell for non-`TextField` controls
/// (`DatePicker`, the category/payment `Menu`s) so they read as part of the
/// same field system instead of falling back to stock iOS control chrome.
private struct LabeledFieldShell<Content: View>: View {
let label: String
let content: Content
init(label: String, @ViewBuilder content: () -> Content) {
self.label = label
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: ZenithSpacing.unit) {
Text(label).zenithLabelCapsStyle()
content
.padding(.vertical, ZenithSpacing.unit * 2)
.overlay(alignment: .bottom) {
Rectangle()
.fill(Color.zenithBorder)
.frame(height: 1)
}
}
}
}

View File

@@ -0,0 +1,135 @@
import PhotosUI
import SwiftUI
import VisionKit
/// Root view of the "Scannen" tab (see `App/MainTabView.swift`). Offers two
/// entry points into the scan flow the system document camera and a photo
/// library fallback uploads whichever image the user provides for AI
/// extraction via `ScanViewModel`, then pushes `ReceiptReviewView` with the
/// result.
struct ScanTabView: View {
@StateObject private var viewModel = ScanViewModel()
@State private var isShowingCamera = false
@State private var cameraUnsupportedAlertPresented = false
@State private var photoPickerItem: PhotosPickerItem?
@State private var extractedReceipt: Receipt?
@State private var isShowingReview = false
var body: some View {
NavigationStack {
ZStack {
Color.zenithBg.ignoresSafeArea()
VStack(spacing: ZenithSpacing.md) {
Spacer()
Image(systemName: "camera.viewfinder")
.font(.system(size: 64))
.foregroundStyle(.zenithSubtle)
Text("Beleg scannen")
.zenithHeadlineLgStyle()
Text("Fotografiere einen Beleg oder wähle ein vorhandenes Foto aus deiner Fotomediathek.")
.zenithBodySmStyle()
.multilineTextAlignment(.center)
.padding(.horizontal, ZenithSpacing.md)
VStack(spacing: ZenithSpacing.xs) {
Button {
startCamera()
} label: {
Label("Kamera", systemImage: "camera")
.frame(maxWidth: .infinity)
}
.buttonStyle(.zenithPrimary)
.disabled(viewModel.isUploading)
PhotosPicker(selection: $photoPickerItem, matching: .images) {
Label("Aus Fotos wählen", systemImage: "photo.on.rectangle")
.frame(maxWidth: .infinity)
}
.buttonStyle(.zenithSecondary)
.disabled(viewModel.isUploading)
}
.padding(.horizontal, ZenithSpacing.md)
.padding(.top, ZenithSpacing.xs)
if viewModel.isUploading {
ProgressView("Beleg wird analysiert …")
.tint(.zenithBlack)
.zenithBodySmStyle()
.padding(.top, ZenithSpacing.xs)
}
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
.zenithBodySmStyle(color: .zenithError)
.multilineTextAlignment(.center)
.padding(.horizontal, ZenithSpacing.md)
.padding(.top, ZenithSpacing.unit)
}
Spacer()
Spacer()
}
}
.navigationTitle("Scannen")
.fullScreenCover(isPresented: $isShowingCamera) {
DocumentCameraView { image in
isShowingCamera = false
guard let image else { return }
Task { await processImage(image) }
}
.ignoresSafeArea()
}
.onChange(of: photoPickerItem) { _, newItem in
guard let newItem else { return }
Task {
await handlePickedPhoto(newItem)
photoPickerItem = nil
}
}
.alert("Kamera nicht verfügbar", isPresented: $cameraUnsupportedAlertPresented) {
Button("OK", role: .cancel) {}
} message: {
Text("Die Dokumentenkamera wird auf diesem Gerät nicht unterstützt. Bitte wähle stattdessen ein Foto aus deiner Fotomediathek.")
}
.navigationDestination(isPresented: $isShowingReview) {
if let extractedReceipt {
ReceiptReviewView(receipt: extractedReceipt)
}
}
}
}
private func startCamera() {
if VNDocumentCameraViewController.isSupported {
isShowingCamera = true
} else {
cameraUnsupportedAlertPresented = true
}
}
private func handlePickedPhoto(_ item: PhotosPickerItem) async {
do {
guard let data = try await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) else {
viewModel.errorMessage = "Foto konnte nicht geladen werden."
return
}
await processImage(image)
} catch {
viewModel.errorMessage = "Foto konnte nicht geladen werden."
}
}
private func processImage(_ image: UIImage) async {
if let receipt = await viewModel.uploadAndExtract(image: image) {
extractedReceipt = receipt
isShowingReview = true
}
}
}

View File

@@ -0,0 +1,49 @@
import Foundation
import UIKit
/// Drives the "Beleg scannen" flow: takes a captured/picked `UIImage`,
/// uploads it to `POST /api/scan` for AI extraction, and hands the resulting
/// `Receipt` back to the view. Does NOT persist anything itself saving
/// happens later in `ReceiptReviewView` via `ReceiptsAPI.sync(...)`.
@MainActor
final class ScanViewModel: ObservableObject {
@Published var isUploading = false
@Published var errorMessage: String?
/// Converts the image to JPEG and uploads it for extraction.
/// Returns the extracted `Receipt` on success, or `nil` on failure
/// (in which case `errorMessage` is set to German user-facing text).
func uploadAndExtract(image: UIImage) async -> Receipt? {
guard let data = image.jpegData(compressionQuality: 0.85) else {
errorMessage = "Bild konnte nicht verarbeitet werden."
return nil
}
return await uploadAndExtract(imageData: data)
}
/// Same as above, but takes already-encoded JPEG data directly.
func uploadAndExtract(imageData: Data) async -> Receipt? {
isUploading = true
errorMessage = nil
defer { isUploading = false }
do {
let response = try await ReceiptsAPI.scan(
fileData: imageData,
fileName: "receipt.jpg",
mimeType: "image/jpeg"
)
guard let receipt = response.receipt else {
errorMessage = "Es konnten keine Belegdaten erkannt werden."
return nil
}
return receipt
} catch let error as APIError {
errorMessage = error.localizedDescription
return nil
} catch {
errorMessage = error.localizedDescription
return nil
}
}
}

View File

@@ -0,0 +1,107 @@
import SwiftUI
/// `POST /api/auth/change-password` (see `Networking/AccountAPI.swift`)
/// rotates EVERY session for the account on success, including this
/// device's and the new session is only delivered as a Set-Cookie, which
/// this Bearer-token client never sees. There is no fresh token to recover
/// from that response, so trying to "stay logged in" here would leave the
/// app holding a dead token. Instead: show a brief confirmation, then log
/// out locally and let the user sign back in with the new password. This is
/// a deliberate consequence of how the backend's session rotation works,
/// not an arbitrary UX choice don't "fix" this into a silent success.
struct ChangePasswordView: View {
@EnvironmentObject private var appState: AppState
@State private var currentPassword = ""
@State private var newPassword = ""
@State private var confirmPassword = ""
@State private var isSubmitting = false
@State private var errorMessage: String?
@State private var showSuccessAlert = false
private var passwordsMatch: Bool {
!newPassword.isEmpty && newPassword == confirmPassword
}
private var canSubmit: Bool {
!currentPassword.isEmpty && passwordsMatch && !isSubmitting
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: ZenithSpacing.md) {
ZenithTextField(
label: "Aktuelles Passwort",
text: $currentPassword,
placeholder: "Aktuelles Passwort",
isSecure: true,
textContentType: .password
)
ZenithDivider()
ZenithTextField(
label: "Neues Passwort",
text: $newPassword,
placeholder: "Neues Passwort",
isSecure: true,
textContentType: .newPassword
)
ZenithTextField(
label: "Neues Passwort bestätigen",
text: $confirmPassword,
placeholder: "Neues Passwort bestätigen",
isSecure: true,
textContentType: .newPassword
)
if !confirmPassword.isEmpty && !passwordsMatch {
Text("Die Passwörter stimmen nicht überein.")
.zenithBodySmStyle(color: .zenithError)
}
if let errorMessage {
Text(errorMessage)
.zenithBodySmStyle(color: .zenithError)
}
Button {
Task { await submit() }
} label: {
if isSubmitting {
ProgressView()
.tint(.white)
} else {
Text("Passwort ändern")
}
}
.buttonStyle(.zenithPrimary)
.disabled(!canSubmit)
.padding(.top, ZenithSpacing.sm)
}
.padding(ZenithSpacing.sm)
}
.background(Color.zenithBg)
.navigationTitle("Passwort ändern")
.navigationBarTitleDisplayMode(.inline)
.alert("Passwort geändert", isPresented: $showSuccessAlert) {
Button("OK") {
Task { await appState.logout() }
}
} message: {
Text("Passwort geändert. Bitte melde dich mit dem neuen Passwort erneut an.")
}
}
private func submit() async {
errorMessage = nil
isSubmitting = true
defer { isSubmitting = false }
do {
try await AccountAPI.changePassword(currentPassword: currentPassword, newPassword: newPassword)
showSuccessAlert = true
} catch {
errorMessage = error.localizedDescription
}
}
}

View File

@@ -0,0 +1,121 @@
import SwiftUI
/// Permanent account deletion. Requires typing the account's email exactly
/// (compared against `appState.currentUser?.email`) plus the password before
/// the destructive action becomes enabled, then a second confirmation
/// dialog before the call actually fires.
struct DeleteAccountView: View {
@EnvironmentObject private var appState: AppState
@State private var confirmEmail = ""
@State private var password = ""
@State private var isSubmitting = false
@State private var errorMessage: String?
@State private var showConfirmDialog = false
// TODO: `Models/User.swift` doesn't currently expose whether an account
// is Google-only (i.e. has no password to check).
// `AccountAPI.deleteAccount(password:confirmEmail:)` accepts `password:
// nil` specifically for that case, but there's no signal here to detect
// it, so this screen always shows and requires the password field. Once
// the `User` model exposes something like `hasPassword`, make this field
// optional/hidden for Google-only accounts and pass `password: nil`
// instead of always requiring input here.
private var emailMatches: Bool {
guard let accountEmail = appState.currentUser?.email, !accountEmail.isEmpty else { return false }
return confirmEmail == accountEmail
}
private var canSubmit: Bool {
emailMatches && !password.isEmpty && !isSubmitting
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: ZenithSpacing.md) {
Text("Diese Aktion ist unwiderruflich. Dein Konto sowie alle Belege und Ordner werden dauerhaft gelöscht.")
.zenithBodyStyle(color: .zenithError)
ZenithDivider()
VStack(alignment: .leading, spacing: ZenithSpacing.sm) {
Text("Bestätigung").zenithLabelCapsStyle()
if let email = appState.currentUser?.email {
Text("Gib zur Bestätigung deine E-Mail-Adresse ein: \(email)")
.zenithBodySmStyle()
}
ZenithTextField(
label: "E-Mail-Adresse",
text: $confirmEmail,
placeholder: "E-Mail-Adresse",
keyboardType: .emailAddress,
textContentType: .emailAddress,
autocapitalization: .never,
autocorrectionDisabled: true
)
ZenithTextField(
label: "Passwort",
text: $password,
placeholder: "Passwort",
isSecure: true,
textContentType: .password
)
}
if let errorMessage {
Text(errorMessage)
.zenithBodySmStyle(color: .zenithError)
}
Button(role: .destructive) {
showConfirmDialog = true
} label: {
if isSubmitting {
ProgressView()
.tint(.white)
} else {
Text("Konto endgültig löschen")
}
}
.buttonStyle(.zenithPrimaryDestructive)
.disabled(!canSubmit)
.padding(.top, ZenithSpacing.sm)
}
.padding(ZenithSpacing.sm)
}
.background(Color.zenithBg)
.navigationTitle("Konto löschen")
.navigationBarTitleDisplayMode(.inline)
.confirmationDialog(
"Konto wirklich endgültig löschen?",
isPresented: $showConfirmDialog,
titleVisibility: .visible
) {
Button("Endgültig löschen", role: .destructive) {
Task { await deleteAccount() }
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Diese Aktion kann nicht rückgängig gemacht werden.")
}
}
private func deleteAccount() async {
errorMessage = nil
isSubmitting = true
defer { isSubmitting = false }
do {
try await AccountAPI.deleteAccount(password: password, confirmEmail: confirmEmail)
// The account and its session are already gone server-side
// this just resets local state. RootView (App/RootView.swift)
// switches to the login screen automatically once
// AppState.phase flips to .signedOut.
await appState.logout()
} catch {
errorMessage = error.localizedDescription
}
}
}

View File

@@ -0,0 +1,195 @@
import SwiftUI
/// Paywall sheet shown from `SettingsView`'s "Auf Pro upgraden" button and
/// from `ProjectsListView` when creating a folder fails with `pro_required`.
///
/// Products and prices are loaded from `PurchaseService.loadProducts()`
/// real, StoreKit-localized products via `StoreKitPurchaseService` by
/// default. Nothing here is hardcoded copy; if products fail to load, a
/// retry affordance is shown instead of guessing at prices.
struct PaywallView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var appState: AppState
@State private var products: [PurchaseProduct] = []
@State private var isLoadingProducts = false
@State private var loadErrorMessage: String?
@State private var purchasingProductID: String?
@State private var errorMessage: String?
@State private var purchaseCompletedMessage: String?
@State private var isRestoring = false
private let purchaseService: PurchaseService
init(purchaseService: PurchaseService = StoreKitPurchaseService()) {
self.purchaseService = purchaseService
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: ZenithSpacing.md) {
Text("Unbegrenzt scannen, Ordner anlegen und exportieren.")
.zenithBodyStyle(color: .zenithMuted)
.multilineTextAlignment(.center)
.padding(.horizontal)
content
if !products.isEmpty {
Button {
Task { await restore() }
} label: {
if isRestoring {
ProgressView()
} else {
Text("Käufe wiederherstellen")
}
}
.buttonStyle(.zenithPlain)
.disabled(isRestoring || purchasingProductID != nil)
}
}
.padding(ZenithSpacing.sm)
}
.background(Color.zenithBg)
.navigationTitle("ScanReceipts Pro")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Schließen") { dismiss() }
}
}
.task {
await loadProducts()
}
.alert("Kauf nicht möglich", isPresented: errorAlertBinding) {
Button("OK", role: .cancel) {}
} message: {
Text(errorMessage ?? "")
}
.alert("Kauf abgeschlossen", isPresented: purchaseCompletedAlertBinding) {
Button("OK", role: .cancel) { dismiss() }
} message: {
Text(purchaseCompletedMessage ?? "")
}
}
}
@ViewBuilder
private var content: some View {
if isLoadingProducts && products.isEmpty {
ProgressView()
.padding(.vertical, ZenithSpacing.lg)
} else if let loadErrorMessage, products.isEmpty {
VStack(spacing: ZenithSpacing.xs) {
Text(loadErrorMessage)
.zenithBodySmStyle()
.multilineTextAlignment(.center)
Button("Erneut versuchen") {
Task { await loadProducts() }
}
.buttonStyle(.zenithPlain)
}
.padding(.vertical, ZenithSpacing.lg)
} else {
ForEach(products) { product in
productCard(product)
}
}
}
private var errorAlertBinding: Binding<Bool> {
Binding(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)
}
private var purchaseCompletedAlertBinding: Binding<Bool> {
Binding(
get: { purchaseCompletedMessage != nil },
set: { if !$0 { purchaseCompletedMessage = nil } }
)
}
private func productCard(_ product: PurchaseProduct) -> some View {
VStack(alignment: .leading, spacing: ZenithSpacing.xs) {
Text(product.displayName)
.zenithHeadlineMdStyle()
Text(priceLabel(for: product))
.zenithLabelMdStyle()
Button {
Task { await purchase(product) }
} label: {
if purchasingProductID == product.id {
ProgressView()
.tint(.white)
} else {
Text("Kaufen")
}
}
.buttonStyle(.zenithPrimary)
.disabled(purchasingProductID != nil)
.opacity(purchasingProductID != nil && purchasingProductID != product.id ? 0.5 : 1)
.padding(.top, ZenithSpacing.xs)
}
.frame(maxWidth: .infinity, alignment: .leading)
.zenithCard()
}
private func priceLabel(for product: PurchaseProduct) -> String {
if let period = product.periodDescription {
return "\(product.displayPrice) · \(period)"
}
return product.displayPrice
}
private func loadProducts() async {
isLoadingProducts = true
loadErrorMessage = nil
defer { isLoadingProducts = false }
do {
products = try await purchaseService.loadProducts()
} catch {
loadErrorMessage = error.localizedDescription
}
}
/// On success (`true`), shows a neutral completion message and dismisses
/// once the user acknowledges it. `POST /api/webhooks/apple` now exists
/// server-side and (given a resolvable `appAccountToken`, see
/// `User.appleAccountToken`) does grant Pro from this purchase but
/// that whole path is genuinely unverified end-to-end (no real Apple
/// Developer account/device available to generate a live transaction
/// against it), and delivery of Apple's server notification isn't
/// synchronous with the purchase sheet closing. So: still no "You're Pro
/// now!" claim here that would assert something this code hasn't
/// actually observed happening. `false` means the user cancelled the
/// StoreKit sheet, not an error, no message needed. A thrown error is
/// surfaced in the existing error alert.
private func purchase(_ product: PurchaseProduct) async {
purchasingProductID = product.id
defer { purchasingProductID = nil }
do {
let completed = try await purchaseService.purchase(
productID: product.id,
appAccountToken: appState.currentUser?.appleAccountToken
)
if completed {
purchaseCompletedMessage = "Kauf abgeschlossen."
}
} catch {
errorMessage = error.localizedDescription
}
}
private func restore() async {
isRestoring = true
defer { isRestoring = false }
do {
try await purchaseService.restorePurchases()
} catch {
errorMessage = error.localizedDescription
}
}
}

View File

@@ -0,0 +1,222 @@
import SwiftUI
/// Maps a stored `Project.color` key (see `Models/Project.swift`'s
/// `colorPalette`) to a display color. This is purely a presentation choice
/// and intentionally lives here rather than in `Models/Project.swift`, which
/// is foundation code shared with other features.
extension Project {
static func displayColor(for key: String?) -> Color {
switch key {
case "blue": return .blue
case "emerald": return .green
case "amber": return .orange
case "rose": return .pink
case "violet": return .purple
case "slate": return .gray
default: return .gray
}
}
}
/// `GET /api/projects` isn't Pro-gated, so this list is shown to every user
/// only creating (and, per the backend, renaming/deleting) requires Pro. A
/// free-plan user can therefore see their existing folders read-only-ish;
/// tapping "+" and trying to create one surfaces the paywall instead of a
/// generic error (see `createProject`).
struct ProjectsListView: View {
@State private var projects: [Project] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var showAddSheet = false
@State private var showPaywall = false
var body: some View {
List {
if projects.isEmpty && !isLoading {
Text("Noch keine Ordner vorhanden.")
.zenithBodySmStyle()
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
}
ForEach(projects) { project in
HStack {
Rectangle()
.fill(Project.displayColor(for: project.color))
.frame(width: 12, height: 12)
Text(project.name)
.zenithBodyStyle()
Spacer()
Text("\(project.receiptCount)")
.zenithLabelMdStyle(color: .zenithMuted)
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
}
.onDelete(perform: deleteProjects)
}
.zenithListBackground()
.navigationTitle("Ordner")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showAddSheet = true
} label: {
Image(systemName: "plus")
.foregroundStyle(Color.zenithBlack)
}
}
}
.task {
await loadProjects()
}
.refreshable {
await loadProjects()
}
.alert("Fehler", isPresented: errorAlertBinding) {
Button("OK", role: .cancel) {}
} message: {
Text(errorMessage ?? "")
}
.sheet(isPresented: $showAddSheet) {
AddProjectSheet(onCreate: createProject)
}
.sheet(isPresented: $showPaywall) {
PaywallView()
}
}
private var errorAlertBinding: Binding<Bool> {
Binding(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)
}
private func loadProjects() async {
isLoading = true
defer { isLoading = false }
do {
projects = try await ProjectsAPI.list()
} catch {
errorMessage = error.localizedDescription
}
}
/// Returns `true` on success so `AddProjectSheet` knows to dismiss
/// itself. On a `pro_required` failure this dismisses the add sheet
/// (`showAddSheet = false`) and presents the paywall instead of a
/// generic error alert the whole reason this isn't just a plain
/// `catch { errorMessage = ... }`.
private func createProject(name: String, color: String?) async -> Bool {
do {
let project = try await ProjectsAPI.create(name: name, color: color)
projects.append(project)
return true
} catch let APIError.server(code, _, _) where code == "pro_required" {
showAddSheet = false
showPaywall = true
return false
} catch {
errorMessage = error.localizedDescription
return false
}
}
private func deleteProjects(at offsets: IndexSet) {
let toDelete = offsets.map { projects[$0] }
projects.remove(atOffsets: offsets)
Task {
for project in toDelete {
do {
try await ProjectsAPI.delete(id: project.id)
} catch {
// Reload from the server so the list reflects reality
// rather than guessing the removed row's original index.
await loadProjects()
errorMessage = error.localizedDescription
break
}
}
}
}
}
/// Simple add-folder sheet: name + a row of tappable color circles built
/// from `Project.colorPalette`. `onCreate` returns whether the create
/// succeeded on `false` the sheet stays open only if the parent left
/// `showAddSheet` true (it won't, for `pro_required`, but does for any other
/// error so the user can retry without losing their typed name).
private struct AddProjectSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var selectedColor: String? = Project.colorPalette.first
@State private var isSaving = false
let onCreate: (String, String?) async -> Bool
private var trimmedName: String {
name.trimmingCharacters(in: .whitespacesAndNewlines)
}
var body: some View {
NavigationStack {
Form {
Section {
ZenithTextField(
label: "Name",
text: $name,
placeholder: "Ordnername"
)
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
Section {
HStack(spacing: 12) {
ForEach(Project.colorPalette, id: \.self) { colorKey in
Rectangle()
.fill(Project.displayColor(for: colorKey))
.frame(width: 28, height: 28)
.overlay {
if selectedColor == colorKey {
Rectangle().strokeBorder(Color.zenithBlack, lineWidth: 2)
}
}
.onTapGesture {
selectedColor = colorKey
}
}
}
.padding(.vertical, 4)
} header: {
Text("Farbe").zenithLabelCapsStyle()
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
}
.zenithListBackground()
.navigationTitle("Neuer Ordner")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button {
Task {
isSaving = true
let success = await onCreate(trimmedName, selectedColor)
isSaving = false
if success { dismiss() }
}
} label: {
if isSaving {
ProgressView()
} else {
Text("Erstellen")
}
}
.disabled(trimmedName.isEmpty || isSaving)
}
}
}
}
}

View File

@@ -0,0 +1,97 @@
import Foundation
/// Abstraction over "load Pro plans and buy one" so `PaywallView` doesn't
/// need to know whether it's talking to real StoreKit or a stub/preview
/// implementation.
///
/// Apple requires (App Store Review Guideline 3.1.1) that any digital
/// feature unlocked *inside* the app (here: Pro status more scans,
/// folders, export) be sold via Apple's own In-App Purchase, not an
/// external payment link, when the purchase is initiated from inside the
/// app. This protocol is deliberately StoreKit-shaped for that reason.
///
/// Product identifiers (reverse-DNS, matching the app's bundle ID
/// `app.scan-receipts.ios` from project.yml) these must be created with
/// EXACTLY these identifiers in App Store Connect (real submission) and/or
/// `Configuration.storekit` (local Simulator testing, see that file) before
/// `StoreKitPurchaseService.loadProducts()` will return anything:
/// - `app.scan-receipts.ios.pro.weekly` auto-renewable subscription
/// - `app.scan-receipts.ios.pro.annual` auto-renewable subscription
/// - `app.scan-receipts.ios.pro.lifetime` non-consumable
enum PurchaseProductID {
static let weekly = "app.scan-receipts.ios.pro.weekly"
static let annual = "app.scan-receipts.ios.pro.annual"
static let lifetime = "app.scan-receipts.ios.pro.lifetime"
static let all = [weekly, annual, lifetime]
}
/// One purchasable plan, already localized/priced by StoreKit (or filled in
/// with placeholder copy by `StubPurchaseService`). `planID` is the
/// backend's own plan vocabulary (`"weekly" | "annual" | "lifetime"` see
/// `users.plan` in `src/lib/schema/db.ts`), kept separate from the StoreKit
/// product identifier since they're different namespaces that happen to be
/// related, not the same thing.
struct PurchaseProduct: Identifiable, Equatable {
let id: String // StoreKit product identifier (PurchaseProductID.*)
let planID: String
let displayName: String
let displayPrice: String
/// e.g. "per week" nil for the one-time lifetime product.
let periodDescription: String?
}
protocol PurchaseService {
/// Fetches the current products with their StoreKit-localized prices.
/// Call before showing `PaywallView`'s plan list never hardcode prices,
/// Apple requires displaying the actual localized App Store price.
func loadProducts() async throws -> [PurchaseProduct]
/// Initiates a purchase for the given StoreKit product identifier.
/// Returns `true` only once the transaction is verified and finished;
/// `false` means the user cancelled the sheet (not an error). Throws for
/// an actual failure (network, StoreKit error, failed verification).
///
/// `appAccountToken` should be the signed-in user's own
/// `User.appleAccountToken` StoreKit attaches it to the resulting
/// transaction, and `POST /api/webhooks/apple` on the backend uses it to
/// know which account to grant Pro to (see that property's doc comment).
/// Pass `nil` only if genuinely no user is signed in (shouldn't happen
/// the paywall is only ever shown to a signed-in account but a
/// purchase with no attributable owner is still better than none at
/// all, since `restorePurchases()`/a later manual reconciliation can
/// recover it).
func purchase(productID: String, appAccountToken: UUID?) async throws -> Bool
/// Re-syncs already-owned entitlements wired to a "Käufe
/// wiederherstellen" button (required by App Store guidelines for
/// non-consumable/subscription products) and worth calling once at
/// launch too, since Apple doesn't otherwise notify a fresh install
/// about prior purchases made on the same Apple ID.
func restorePurchases() async throws
}
/// Stub/preview implementation returns illustrative placeholder products
/// (so `PaywallView` has something to render in SwiftUI previews and before
/// `StoreKitPurchaseService` is wired in) and always throws on an actual
/// purchase attempt, since there is nothing real behind it.
final class StubPurchaseService: PurchaseService {
func loadProducts() async throws -> [PurchaseProduct] {
[
PurchaseProduct(id: PurchaseProductID.weekly, planID: "weekly", displayName: "Wochen-Pass", displayPrice: "4,99 €", periodDescription: "pro Woche"),
PurchaseProduct(id: PurchaseProductID.annual, planID: "annual", displayName: "Jahres-Pass", displayPrice: "39,99 €", periodDescription: "pro Jahr"),
PurchaseProduct(id: PurchaseProductID.lifetime, planID: "lifetime", displayName: "Lifetime", displayPrice: "59,99 €", periodDescription: nil),
]
}
func purchase(productID: String, appAccountToken: UUID?) async throws -> Bool {
throw NSError(
domain: "ScanReceipts.Purchase",
code: -1,
userInfo: [
NSLocalizedDescriptionKey: "In-App-Käufe sind in dieser Vorschau-Version noch nicht verfügbar."
]
)
}
func restorePurchases() async throws {}
}

View File

@@ -0,0 +1,162 @@
import SwiftUI
/// Tab root for "Konto" (see `App/MainTabView.swift`, which references
/// `SettingsView()` with a zero-arg initializer as its third tab). Wrapped in
/// its own `NavigationStack` every push (folders, change password, delete
/// account) and the Pro-upgrade sheet originate from here.
struct SettingsView: View {
@EnvironmentObject private var appState: AppState
@State private var showPaywall = false
var body: some View {
NavigationStack {
Form {
profileSection
foldersSection
accountSection
}
.zenithListBackground()
.navigationTitle("Konto")
.sheet(isPresented: $showPaywall) {
PaywallView()
}
}
}
// MARK: - Profil
@ViewBuilder
private var profileSection: some View {
Section {
if let user = appState.currentUser {
VStack(alignment: .leading, spacing: 4) {
Text(user.name?.isEmpty == false ? user.name! : "Unbenannt")
.zenithHeadlineMdStyle()
if let email = user.email {
Text(email)
.zenithBodySmStyle()
}
}
.padding(.vertical, ZenithSpacing.unit)
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
if user.isPro {
proStatusRow(for: user)
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
} else {
freeStatusRow
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
}
}
}
}
private func proStatusRow(for user: User) -> some View {
VStack(alignment: .leading, spacing: ZenithSpacing.xs) {
HStack(spacing: ZenithSpacing.xs) {
Text("PRO")
.zenithLabelCapsStyle(color: .white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.zenithBlack)
ZenithChip(text: planDisplayName(user.plan))
}
if let expiresAt = user.expiresAt, let date = ISO8601.parse(expiresAt) {
let formatted = date.formatted(date: .abbreviated, time: .omitted)
Text(user.cancelAtPeriodEnd ? "Läuft aus am \(formatted)" : "Verlängert sich am \(formatted)")
.zenithLabelMdStyle(color: .zenithMuted)
}
}
.padding(.vertical, ZenithSpacing.unit)
}
private var freeStatusRow: some View {
VStack(alignment: .leading, spacing: ZenithSpacing.xs) {
Text("Kostenlos")
.zenithBodyStyle(color: .zenithMuted)
Button("Auf Pro upgraden") {
showPaywall = true
}
.buttonStyle(.zenithPrimary)
}
.padding(.vertical, ZenithSpacing.unit)
}
private func planDisplayName(_ plan: String) -> String {
switch plan {
case "weekly": return "Wochen-Pass"
case "annual": return "Jahres-Pass"
case "lifetime": return "Lifetime"
case "free": return "Kostenlos"
default: return plan.capitalized
}
}
// MARK: - Ordner
private var foldersSection: some View {
Section {
NavigationLink {
ProjectsListView()
} label: {
Label {
Text("Ordner verwalten").zenithBodyStyle()
} icon: {
Image(systemName: "folder").foregroundStyle(Color.zenithMuted)
}
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
} header: {
Text("Ordner").zenithLabelCapsStyle()
}
}
// MARK: - Konto
private var accountSection: some View {
Section {
NavigationLink {
ChangePasswordView()
} label: {
Label {
Text("Passwort ändern").zenithBodyStyle()
} icon: {
Image(systemName: "lock").foregroundStyle(Color.zenithMuted)
}
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
Button {
Task { await appState.logout() }
} label: {
Label {
Text("Abmelden")
} icon: {
Image(systemName: "arrow.backward.square")
}
}
.buttonStyle(.zenithPlain)
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
NavigationLink {
DeleteAccountView()
} label: {
Label {
Text("Konto löschen").zenithBodyStyle(color: .zenithError)
} icon: {
Image(systemName: "trash").foregroundStyle(Color.zenithError)
}
}
.listRowBackground(Color.zenithSurface)
.listRowSeparatorTint(Color.zenithBorder)
} header: {
Text("Konto").zenithLabelCapsStyle()
}
}
}

View File

@@ -0,0 +1,216 @@
import Foundation
import StoreKit
/// Errors raised locally by `StoreKitPurchaseService` itself (as opposed to
/// errors StoreKit hands back verbatim, e.g. from `VerificationResult`'s
/// `.unverified` case, which are re-thrown as-is). Kept in the same
/// `Error, LocalizedError` + German `errorDescription` shape as `APIError`
/// in `Networking/APIError.swift` so both surface consistently in the UI.
enum StoreKitPurchaseError: Error, LocalizedError {
/// `Product.products(for:)` returned nothing for the requested
/// identifier misconfigured product ID, or not yet propagated in App
/// Store Connect / the local `Configuration.storekit` file.
case productNotFound
var errorDescription: String? {
switch self {
case .productNotFound:
return "Dieses Produkt ist aktuell nicht verfügbar. Bitte versuche es später erneut."
}
}
}
/// Real StoreKit 2 implementation of `PurchaseService` (see
/// `Features/Settings/PurchaseService.swift` for the protocol and the
/// App Store Review Guideline 3.1.1 background on why this exists at all).
///
/// NOTE ON SERVER-SIDE ENTITLEMENT SYNC: this class only talks to StoreKit /
/// Apple's servers. It does **not** update `users.plan` on the backend see
/// the long comment on `updatesTask` below, this is the important caveat to
/// understand before assuming a successful purchase here means the account
/// is actually Pro.
final class StoreKitPurchaseService: PurchaseService {
/// Background listener for transactions that complete outside the
/// synchronous `purchase()` call (renewals, refunds, Ask-to-Buy
/// approvals, purchases made on another device). Started once, for the
/// lifetime of this service instance.
private let updatesTask: Task<Void, Never>
init() {
updatesTask = Task.detached {
for await result in Transaction.updates {
await StoreKitPurchaseService.handleUpdatedTransaction(result)
}
}
}
deinit {
updatesTask.cancel()
}
// MARK: - PurchaseService
func loadProducts() async throws -> [PurchaseProduct] {
let products = try await Product.products(for: PurchaseProductID.all)
let mapped: [PurchaseProduct] = products.compactMap { product in
let planID: String
switch product.id {
case PurchaseProductID.weekly:
planID = "weekly"
case PurchaseProductID.annual:
planID = "annual"
case PurchaseProductID.lifetime:
planID = "lifetime"
default:
// Unknown identifier (shouldn't happen we only asked for
// PurchaseProductID.all but never silently invent a plan
// for something we don't recognise).
return nil
}
let periodDescription: String?
if product.type == .nonConsumable {
periodDescription = nil
} else if let subscription = product.subscription {
periodDescription = StoreKitPurchaseService.periodDescription(for: subscription.subscriptionPeriod)
} else {
periodDescription = nil
}
return PurchaseProduct(
id: product.id,
planID: planID,
displayName: product.displayName,
displayPrice: product.displayPrice,
periodDescription: periodDescription
)
}
// Render in a fixed, sensible order regardless of what the store
// returns PaywallView renders in the order we give back.
let order = [PurchaseProductID.weekly, PurchaseProductID.annual, PurchaseProductID.lifetime]
return mapped.sorted { lhs, rhs in
let lhsIndex = order.firstIndex(of: lhs.id) ?? order.count
let rhsIndex = order.firstIndex(of: rhs.id) ?? order.count
return lhsIndex < rhsIndex
}
}
func purchase(productID: String, appAccountToken: UUID?) async throws -> Bool {
guard let product = try await Product.products(for: [productID]).first else {
throw StoreKitPurchaseError.productNotFound
}
// Ties the resulting transaction back to this app's own user id so
// the backend (POST /api/webhooks/apple) knows whose account to
// grant Pro to see `User.appleAccountToken`'s doc comment for
// exactly how that round-trip works. Purchasing without it (nil)
// still completes the purchase on Apple's side, it just can't be
// reconciled server-side without a manual look-up later.
var options: Set<Product.PurchaseOption> = []
if let appAccountToken {
options.insert(.appAccountToken(appAccountToken))
}
let result = try await product.purchase(options: options)
switch result {
case .success(let verificationResult):
switch verificationResult {
case .verified(let transaction):
await transaction.finish()
return true
case .unverified(_, let error):
// StoreKit couldn't verify the transaction's signature (e.g.
// jailbroken device, tampered receipt) surface the
// underlying error rather than treating it as a success.
throw error
}
case .userCancelled:
// Not an error the user backed out of the purchase sheet.
return false
case .pending:
// Ask-to-Buy / family approval pending. Nothing more to do
// synchronously; the eventual approval (or denial) arrives later
// via Transaction.updates, handled by `updatesTask` below.
return false
@unknown default:
return false
}
}
func restorePurchases() async throws {
try await AppStore.sync()
}
// MARK: - Transaction updates listener
/// Verifies and finishes a transaction observed via `Transaction.updates`.
///
/// IMPORTANT this is genuinely incomplete, documented honestly rather
/// than hidden: finishing a transaction here only tells StoreKit "this
/// purchase has been dealt with, stop re-presenting it" it does
/// **not** unlock Pro server-side. The backend has no
/// `/api/webhooks/apple` endpoint yet to reconcile a StoreKit purchase
/// into `users.plan` (see `app/ios/README.md`'s "What's deliberately NOT
/// done yet" section) that reconciliation is separate, not-yet-built
/// backend work driven by Apple's server-to-server notifications. Do
/// NOT be tempted to set some local "is Pro" flag here as a substitute:
/// the account's real Pro status lives on the server (`users.plan`),
/// and nothing in this file can change that yet.
private static func handleUpdatedTransaction(_ result: VerificationResult<Transaction>) async {
switch result {
case .verified(let transaction):
await transaction.finish()
case .unverified:
// Can't verify it; leave it alone rather than finishing a
// transaction we couldn't authenticate. It will be re-delivered.
break
}
}
// MARK: - Formatting helpers
/// Turns a `Product.SubscriptionPeriod` into a short German phrase, e.g.
/// `(unit: .week, value: 1)` -> "pro Woche", `(unit: .year, value: 1)`
/// -> "pro Jahr", `(unit: .month, value: 3)` -> "alle 3 Monate". This app
/// currently only ever configures value == 1 periods (see
/// `Configuration.storekit`), but the multi-value phrasing is filled in
/// so this doesn't silently misrender if that ever changes.
private static func periodDescription(for period: Product.SubscriptionPeriod) -> String {
let value = period.value
if value == 1 {
switch period.unit {
case .day:
return "pro Tag"
case .week:
return "pro Woche"
case .month:
return "pro Monat"
case .year:
return "pro Jahr"
@unknown default:
return "pro Abrechnungszeitraum"
}
}
switch period.unit {
case .day:
return "alle \(value) Tage"
case .week:
return "alle \(value) Wochen"
case .month:
return "alle \(value) Monate"
case .year:
return "alle \(value) Jahre"
@unknown default:
return "alle \(value) Abrechnungszeiträume"
}
}
}

View File

@@ -0,0 +1,26 @@
import Foundation
/// Mirrors the objects returned by `GET /api/projects` (src/app/api/projects/route.ts).
/// Projects are Pro-only "folders" receipts can be filed under.
struct Project: Codable, Identifiable, Equatable {
let id: String
var name: String
var color: String?
var receiptCount: Int
let createdAt: String
let updatedAt: String
/// Fixed palette the web app offers when creating/editing a project
/// keep in sync with `PROJECT_COLORS` in src/app/api/projects/route.ts.
static let colorPalette = ["slate", "blue", "emerald", "amber", "rose", "violet"]
}
struct ProjectListResponse: Decodable {
let success: Bool
let projects: [Project]
}
struct CreateProjectRequest: Encodable {
let name: String
let color: String?
}

View File

@@ -0,0 +1,148 @@
import Foundation
/// Mirrors `ProcessedReceipt` (src/lib/schema/receipt.ts) exactly this is
/// the shape returned by `GET /api/receipts` and accepted by
/// `POST /api/receipts`. Keep this file and that Zod schema in sync; when the
/// web schema gains a field, add it here too rather than silently dropping it
/// on decode (optional fields decode fine as `nil` when absent, so older
/// receipts synced before a schema change won't crash the app).
struct Receipt: Codable, Identifiable, Equatable {
let id: String
var projectId: String?
let imageHash: String
let originalFileName: String
let fileSizeBytes: Int
var previewUrl: String?
let createdAt: String
let updatedAt: String
var status: ReceiptStatus
var merchant: Merchant
var date: ReceiptDate
var documentType: DocumentType
var receiptNumber: String?
var currency: String
var totalAmount: AmountWithConfidence
var netAmount: Double?
var tipAmount: Double?
var taxBreakdown: [TaxBreakdownItem]
var lineItems: [LineItem]
var suggestedCategory: ReceiptCategory
var paymentMethod: PaymentMethod?
var hospitality: Hospitality?
var validation: Validation
/// Total actually paid: `totalAmount.value + tipAmount`. Mirrors
/// `grossWithTip()` on the backend tip is deliberately excluded from
/// `totalAmount` there because VAT is never charged on it.
var grossWithTip: Double {
((totalAmount.value + (tipAmount ?? 0)) * 100).rounded() / 100
}
struct Merchant: Codable, Equatable {
var name: String
var address: String?
var taxId: String?
var confidence: Double
}
struct ReceiptDate: Codable, Equatable {
var isoDate: String
var time: String?
var confidence: Double
}
struct AmountWithConfidence: Codable, Equatable {
var value: Double
var confidence: Double
}
struct TaxBreakdownItem: Codable, Equatable, Identifiable {
var ratePercent: Double
var taxAmount: Double
var netAmount: Double?
var id: String { "\(ratePercent)-\(taxAmount)" }
}
struct LineItem: Codable, Equatable, Identifiable {
var id = UUID()
var description: String
var quantity: Double
var price: Double
var unitPrice: Double?
var taxRate: Double?
enum CodingKeys: String, CodingKey {
case description, quantity, price, unitPrice, taxRate
}
}
struct Hospitality: Codable, Equatable {
var occasion: String?
var participants: String?
}
struct Validation: Codable, Equatable {
var isMathValid: Bool
var isDuplicateSuspected: Bool
var needsUserReview: Bool
var reviewField: String
var reviewReason: String?
var issues: [Issue]?
var userConfirmed: Bool?
struct Issue: Codable, Equatable {
var field: String
var severity: String
var message: String
}
}
enum ReceiptStatus: String, Codable {
case pending, processing, ready, needsReview = "needs_review", error
}
enum DocumentType: String, Codable, CaseIterable {
case kassenbon = "KASSENBON"
case rechnung = "RECHNUNG"
case tankbeleg = "TANKBELEG"
case bewirtungsbeleg = "BEWIRTUNGSBELEG"
case parkticket = "PARKTICKET"
case sonstiges = "SONSTIGES"
}
enum PaymentMethod: String, Codable, CaseIterable {
case bar = "BAR"
case ecKarte = "EC_KARTE"
case kreditkarte = "KREDITKARTE"
case ueberweisung = "UEBERWEISUNG"
case paypal = "PAYPAL"
case applePay = "APPLE_PAY"
case googlePay = "GOOGLE_PAY"
case sonstige = "SONSTIGE"
}
enum ReceiptCategory: String, Codable, CaseIterable {
case bewirtung = "Bewirtung"
case reisekosten = "Reisekosten & Hotel"
case tanken = "Tanken & KFZ"
case buerobedarf = "Bürobedarf & IT"
case verpflegungsmehraufwand = "Verpflegungsmehraufwand"
case material = "Material & Einkauf"
case sonstiges = "Sonstiges"
}
}
/// `GET /api/receipts` response envelope.
struct ReceiptListResponse: Decodable {
let success: Bool
let receipts: [Receipt]
let count: Int
}
/// `POST /api/receipts` response envelope (batch upsert).
struct ReceiptSyncResponse: Decodable {
let success: Bool
let syncedCount: Int?
let message: String?
}

View File

@@ -0,0 +1,88 @@
import Foundation
/// Mirrors the `user` object returned by `GET /api/auth/session` and
/// `POST /api/auth/login` (src/app/api/auth/session/route.ts). Field names
/// are already camelCase in the JSON, so no custom `CodingKeys`/key-decoding
/// strategy is needed anywhere in this file.
///
/// Date fields are kept as raw ISO-8601 strings (with fractional seconds,
/// e.g. `2026-08-20T19:37:42.307Z` from `Date.toISOString()`) rather than
/// `Date`, because `JSONDecoder`'s built-in `.iso8601` strategy does NOT
/// parse fractional seconds and would silently fail to decode every
/// timestamp this backend sends. Use `ISO8601.parse(_:)` (Support/ISO8601.swift)
/// where you actually need a `Date`.
struct User: Decodable, Identifiable, Equatable {
let id: String
let email: String?
let name: String?
let plan: String
let isGuest: Bool
let isPro: Bool
let cancelAtPeriodEnd: Bool
let emailVerified: Bool
let launchBonus: Bool
let freeScanAllowance: Int
let scanCount: Int
let company: String?
let useCase: String?
let expiresAt: String?
let createdAt: String?
let onboardingCompletedAt: String?
/// True once onboarding (company/use-case/etc.) has been completed the
/// dashboard's post-signup wizard on web, skippable but tracked the same
/// way in the app.
var hasCompletedOnboarding: Bool { onboardingCompletedAt != nil }
/// Reconstructs the UUID `newId("usr")` originally minted this account's
/// `id` from (see `src/lib/auth/tokens.ts` on the backend: `"usr_" +
/// randomUUID().replace(/-/g, "")` i.e. `id` IS a UUID with its dashes
/// stripped and a prefix glued on, nothing more). Re-inserting the
/// dashes recovers that exact UUID losslessly no extra network call,
/// no server-issued token to fetch and cache.
///
/// Passed as StoreKit's `appAccountToken` on purchase (see
/// `StoreKitPurchaseService.purchase`) so `POST /api/webhooks/apple`
/// (`src/lib/billing/appleIAP.ts` `userIdFromAppAccountToken`) can
/// reverse the same transformation server-side and know which account a
/// given App Store transaction belongs to that's the ONLY thing this
/// value is for; it carries no other meaning to Apple.
var appleAccountToken: UUID? {
guard id.hasPrefix("usr_") else { return nil }
let hex = String(id.dropFirst(4))
guard hex.count == 32, hex.allSatisfy(\.isHexDigit) else { return nil }
let parts = [
hex.prefix(8),
hex.dropFirst(8).prefix(4),
hex.dropFirst(12).prefix(4),
hex.dropFirst(16).prefix(4),
hex.dropFirst(20),
]
return UUID(uuidString: parts.joined(separator: "-"))
}
}
/// The minimal user echo returned inline by `POST /api/auth/login` and
/// `/signup` (a subset of `User` those endpoints don't run the extra
/// queries `/api/auth/session` does). Kept separate so a login response
/// decodes without requiring fields it doesn't send.
struct LoginResponse: Decodable {
let status: String
let user: LoginUser
/// Present only when the request carried `X-Client: ios` see
/// src/app/api/auth/login/route.ts. Absent for a browser-style caller.
let token: String?
let expiresAt: String?
}
struct LoginUser: Decodable {
let id: String
let email: String?
let name: String?
let plan: String
let onboardingCompletedAt: String?
}
struct SessionResponse: Decodable {
let user: User?
}

View File

@@ -0,0 +1,189 @@
import Foundation
/// One HTTP call against the backend. Feature code builds one of these and
/// hands it to `APIClient.shared.send(...)` nobody outside this file talks
/// to `URLSession` directly, so auth headers and error decoding stay in
/// exactly one place.
struct APIRequest {
enum Method: String { case get = "GET", post = "POST", patch = "PATCH", delete = "DELETE" }
var path: String
var method: Method = .get
var query: [URLQueryItem] = []
var body: Data?
var contentType: String = "application/json"
/// False only for the couple of endpoints callable while signed out
/// (login, signup, forgot-password). Everything else sends the Bearer
/// token automatically.
var requiresAuth: Bool = true
static func json<Body: Encodable>(
path: String,
method: Method,
body: Body,
requiresAuth: Bool = true
) throws -> APIRequest {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return APIRequest(
path: path,
method: method,
body: try encoder.encode(body),
requiresAuth: requiresAuth
)
}
/// Builds a single-file `multipart/form-data` body used by
/// `POST /api/scan`, which reads the file from a `file` form field (see
/// `formData.get("file")` in src/app/api/scan/route.ts). Only one file
/// field is needed anywhere in this app; if that changes, generalise this
/// rather than hand-rolling multipart bodies elsewhere.
static func multipartFile(
path: String,
fieldName: String = "file",
fileName: String,
mimeType: String,
fileData: Data
) -> APIRequest {
let boundary = "ScanReceipts-\(UUID().uuidString)"
var body = Data()
body.append("--\(boundary)\r\n".utf8)
body.append("Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(fileName)\"\r\n".utf8)
body.append("Content-Type: \(mimeType)\r\n\r\n".utf8)
body.append(fileData)
body.append("\r\n--\(boundary)--\r\n".utf8)
return APIRequest(
path: path,
method: .post,
body: body,
contentType: "multipart/form-data; boundary=\(boundary)",
requiresAuth: true
)
}
}
/// Posted whenever a request comes back 401. `AppState` observes this and
/// signs the user out locally the server-side session is already gone by
/// the time this fires, so there is nothing left to clean up remotely.
extension Notification.Name {
static let sessionExpired = Notification.Name("ScanReceipts.sessionExpired")
}
final class APIClient {
static let shared = APIClient()
private let session: URLSession
private let baseURL: URL
private let tokenStore: KeychainTokenStore
init(
baseURL: URL = APIEnvironment.current.baseURL,
session: URLSession = .shared,
tokenStore: KeychainTokenStore = .shared
) {
self.baseURL = baseURL
self.session = session
self.tokenStore = tokenStore
}
/// Decodes a JSON response body as `T`.
@discardableResult
func send<T: Decodable>(_ request: APIRequest, as type: T.Type = T.self) async throws -> T {
let data = try await sendRaw(request)
do {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decoding(error)
}
}
/// For endpoints that return a raw file, not JSON the three
/// `/api/export/*` routes, which respond with the file bytes directly and
/// the filename in `Content-Disposition` (see ExportAPI.swift). Still
/// validates the status and decodes `{ error }` JSON on failure exactly
/// like `send`.
func sendForFile(_ request: APIRequest) async throws -> (data: Data, suggestedFileName: String?) {
let (data, response) = try await sendRawWithResponse(request)
let fileName = response.value(forHTTPHeaderField: "Content-Disposition")
.flatMap { header -> String? in
guard let range = header.range(of: "filename=\"") else { return nil }
let rest = header[range.upperBound...]
guard let end = rest.firstIndex(of: "\"") else { return nil }
return String(rest[rest.startIndex..<end])
}
return (data, fileName)
}
/// For endpoints whose success body carries nothing the caller needs
/// (e.g. `DELETE /api/receipts`) still validates the status code and
/// decodes `{ error }` on failure.
func sendDiscardingResponse(_ request: APIRequest) async throws {
_ = try await sendRaw(request)
}
private func sendRaw(_ apiRequest: APIRequest) async throws -> Data {
try await sendRawWithResponse(apiRequest).data
}
private func sendRawWithResponse(_ apiRequest: APIRequest) async throws -> (data: Data, response: HTTPURLResponse) {
var components = URLComponents(url: baseURL.appendingPathComponent(apiRequest.path), resolvingAgainstBaseURL: false)
if !apiRequest.query.isEmpty {
components?.queryItems = apiRequest.query
}
guard let url = components?.url else {
throw APIError.unexpectedStatus(-1)
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = apiRequest.method.rawValue
// Identifies this as a native client so the backend (a) returns the
// raw session token in the login/signup JSON body instead of only a
// Set-Cookie, and (b) exempts the request from the browser-only CSRF
// double-submit check. See src/lib/auth/config.ts#isMobileClientRequest
// and src/lib/auth/csrf.ts#isCsrfExempt on the backend.
urlRequest.setValue("ios", forHTTPHeaderField: "X-Client")
if let body = apiRequest.body {
urlRequest.httpBody = body
urlRequest.setValue(apiRequest.contentType, forHTTPHeaderField: "Content-Type")
}
if apiRequest.requiresAuth {
guard let token = tokenStore.token else {
throw APIError.unauthorized
}
urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: urlRequest)
} catch {
throw APIError.network(error)
}
guard let http = response as? HTTPURLResponse else {
throw APIError.unexpectedStatus(-1)
}
if (200..<300).contains(http.statusCode) {
return (data, http)
}
if http.statusCode == 401 {
tokenStore.token = nil
NotificationCenter.default.post(name: .sessionExpired, object: nil)
throw APIError.unauthorized
}
if let body = try? JSONDecoder().decode(APIErrorBody.self, from: data) {
throw APIError.server(code: body.error, status: http.statusCode, retryAfterSeconds: body.retryAfter)
}
throw APIError.unexpectedStatus(http.statusCode)
}
}

View File

@@ -0,0 +1,30 @@
import Foundation
/// Which backend the app talks to. All three point at the *same* Next.js API
/// described in `app/ios/README.md` there is no separate mobile backend.
enum APIEnvironment {
case production
case local
var baseURL: URL {
switch self {
case .production:
// TODO: swap for the real production domain before release.
return URL(string: "https://scan-receipts.app")!
case .local:
// Matches `dev-verify` in the web repo's .claude/launch.json (port
// 3901) so the app can be pointed at a live local backend without
// colliding with the docker-compose stack on 3000. Only reachable
// from an iOS Simulator on the same machine, not a physical device.
return URL(string: "http://localhost:3901")!
}
}
static var current: APIEnvironment {
#if DEBUG
return .local
#else
return .production
#endif
}
}

View File

@@ -0,0 +1,78 @@
import Foundation
/// A decoded `{ "error": "some_code" }` body from the backend. Every route
/// under `src/app/api/**` returns one of these machine-readable codes on
/// failure (see `src/lib/auth/errors.ts` for the auth ones) the app is
/// responsible for turning the code into user-facing German copy, the same
/// way the web dashboard's client code does.
struct APIErrorBody: Decodable {
let error: String
let retryAfter: Int?
}
enum APIError: Error, LocalizedError {
/// HTTP 401 no/invalid/expired session. Callers should route back to
/// the login screen and clear the stored token.
case unauthorized
/// A recognised `{ error: <code> }` body, with the HTTP status attached
/// (403 pro_required, 402 scan_limit_reached, 429 rate_limited, ...).
case server(code: String, status: Int, retryAfterSeconds: Int?)
/// A non-2xx response with a body that isn't the `{ error }` shape.
case unexpectedStatus(Int)
case network(Error)
case decoding(Error)
var errorDescription: String? {
switch self {
case .unauthorized:
return "Bitte melde dich erneut an."
case .server(let code, _, let retryAfter):
return APIError.message(forCode: code, retryAfterSeconds: retryAfter)
case .unexpectedStatus(let status):
return "Unerwartete Antwort vom Server (\(status))."
case .network:
return "Keine Verbindung zum Server. Prüfe deine Internetverbindung."
case .decoding:
return "Antwort des Servers konnte nicht gelesen werden."
}
}
/// Central place to translate a backend error code into German UI copy.
/// Extend this switch as features land it deliberately mirrors what the
/// web app's route handlers can return, not a guess at future codes.
static func message(forCode code: String, retryAfterSeconds: Int?) -> String {
switch code {
case "invalid_credentials":
return "E-Mail oder Passwort ist falsch."
case "email_not_verified":
return "Bitte bestätige zuerst deine E-Mail-Adresse."
case "rate_limited":
if let seconds = retryAfterSeconds, seconds > 0 {
return "Zu viele Versuche. Bitte in \(seconds) Sekunden erneut versuchen."
}
return "Zu viele Versuche. Bitte später erneut versuchen."
case "invalid_email":
return "Diese E-Mail-Adresse ist ungültig."
case "weak_password":
return "Das Passwort ist zu schwach."
case "disposable_email":
return "Bitte verwende eine reguläre E-Mail-Adresse."
case "mail_failed":
return "Bestätigungs-E-Mail konnte nicht gesendet werden. Bitte später erneut versuchen."
case "database_unavailable":
return "Server vorübergehend nicht erreichbar. Bitte später erneut versuchen."
case "pro_required":
return "Diese Funktion ist Pro-Nutzern vorbehalten."
case "scan_limit_reached":
return "Dein monatliches Scan-Kontingent ist aufgebraucht."
case "daily_scan_limit_reached":
return "Tageslimit für Scans erreicht. Bitte morgen erneut versuchen."
case "csrf_failed":
return "Sicherheitsprüfung fehlgeschlagen. Bitte App neu starten."
case "unauthorized":
return "Bitte melde dich erneut an."
default:
return "Etwas ist schiefgelaufen (\(code))."
}
}
}

View File

@@ -0,0 +1,46 @@
import Foundation
/// Account-management calls that aren't part of the core auth flow
/// (`AuthAPI`) password change and the irreversible delete-account flow.
enum AccountAPI {
struct ChangePasswordBody: Encodable {
let currentPassword: String
let newPassword: String
let remember: Bool
}
/// `POST /api/auth/change-password`. On success the backend rotates
/// EVERY session for the account (including this device's) and issues a
/// brand-new one but that new session is delivered as a cookie on the
/// web path only. For a Bearer client the safest move is to log the user
/// out locally and have them log back in with the new password, so do
/// that here rather than trying to recover a token this endpoint doesn't
/// return.
static func changePassword(currentPassword: String, newPassword: String) async throws {
let request = try APIRequest.json(
path: "/api/auth/change-password",
method: .post,
body: ChangePasswordBody(currentPassword: currentPassword, newPassword: newPassword, remember: true)
)
try await APIClient.shared.sendDiscardingResponse(request)
}
struct DeleteAccountBody: Encodable {
let password: String?
let confirmEmail: String
}
/// `DELETE /api/auth/delete-account`. Irreversible. `password` is
/// required for accounts that have one (omit only for a Google-only
/// account, which has no password to check); `confirmEmail` must equal
/// the account's email exactly, case-sensitively, matching the web
/// confirmation flow's "type your email to confirm" pattern.
static func deleteAccount(password: String?, confirmEmail: String) async throws {
let request = try APIRequest.json(
path: "/api/auth/delete-account",
method: .delete,
body: DeleteAccountBody(password: password, confirmEmail: confirmEmail)
)
try await APIClient.shared.sendDiscardingResponse(request)
}
}

View File

@@ -0,0 +1,95 @@
import Foundation
/// Thin wrapper around the `/api/auth/*` endpoints. `AppState` is the only
/// caller that should hold state derived from these feature screens call
/// through `AppState`, not this type directly, so there is exactly one place
/// that decides what "signed in" means.
enum AuthAPI {
struct SignupBody: Encodable {
let name: String?
let email: String
let password: String
let lang: String
}
struct SignupResponse: Decodable {
let status: String
/// Only ever present against a local dev backend with no SMTP
/// configured (see src/app/api/auth/signup/route.ts) never in
/// production. Useful for the simulator during development.
let devLink: String?
}
struct LoginBody: Encodable {
let email: String
let password: String
let remember: Bool
}
struct AppleSignInBody: Encodable {
let identityToken: String
let fullName: String?
}
/// `POST /api/auth/apple` native Sign in with Apple. `identityToken` is
/// `ASAuthorizationAppleIDCredential.identityToken` decoded to a UTF-8
/// string; `fullName` is `credential.fullName` formatted for display,
/// which Apple only ever supplies on the user's FIRST authorization with
/// this app (pass `nil` on every subsequent sign-in there is nothing to
/// send). The backend verifies the token itself; nothing here is trusted
/// data, it's just what gets forwarded for verification.
static func appleSignIn(identityToken: String, fullName: String?) async throws -> LoginResponse {
let request = try APIRequest.json(
path: "/api/auth/apple",
method: .post,
body: AppleSignInBody(identityToken: identityToken, fullName: fullName),
requiresAuth: false
)
return try await APIClient.shared.send(request)
}
/// `POST /api/auth/signup`. No session is created the account is inert
/// until the emailed confirmation link is opened, exactly like the web
/// flow. The response is deliberately neutral (see the route's doc
/// comment): callers cannot tell a new signup apart from "already
/// registered" from this response alone.
static func signup(name: String?, email: String, password: String) async throws -> SignupResponse {
let request = try APIRequest.json(
path: "/api/auth/signup",
method: .post,
body: SignupBody(name: name, email: email, password: password, lang: "de"),
requiresAuth: false
)
return try await APIClient.shared.send(request)
}
/// `POST /api/auth/login`. On success the backend returns the raw session
/// token in the body (because the request carries `X-Client: ios` see
/// APIClient) instead of only a Set-Cookie header. The caller is
/// responsible for persisting it via `KeychainTokenStore`.
static func login(email: String, password: String, remember: Bool = true) async throws -> LoginResponse {
let request = try APIRequest.json(
path: "/api/auth/login",
method: .post,
body: LoginBody(email: email, password: password, remember: remember),
requiresAuth: false
)
return try await APIClient.shared.send(request)
}
/// `GET /api/auth/session` "who am I". Never throws for "signed out";
/// that's `{ user: null }`, HTTP 200.
static func session() async throws -> SessionResponse {
try await APIClient.shared.send(APIRequest(path: "/api/auth/session", method: .get))
}
/// `POST /api/auth/logout`. Best-effort: the caller should clear the
/// local token and navigate to the login screen regardless of whether
/// this succeeds (mirrors the web route's own "logout always proceeds"
/// philosophy).
static func logout() async throws {
try await APIClient.shared.sendDiscardingResponse(
APIRequest(path: "/api/auth/logout", method: .post)
)
}
}

View File

@@ -0,0 +1,55 @@
import Foundation
/// `/api/export/{csv,excel,pdf}` Pro-only (403 `pro_required` for a
/// free-plan account, surfaced via `APIError.server`). Every route takes the
/// same request body and returns the raw file with its name in
/// `Content-Disposition`; present the returned `Data` via `UIActivityViewController`
/// (share sheet) or write it into a temp file and use `.fileExporter` /
/// `QLPreviewController` either is fine, this layer just gets you the bytes.
enum ExportAPI {
enum Format: String {
case csv, excel, pdf
var path: String { "/api/export/\(rawValue)" }
var fallbackExtension: String {
switch self {
case .csv: return "csv"
case .excel: return "xlsx"
case .pdf: return "pdf"
}
}
}
struct ExportRequestBody: Encodable {
let receipts: [Receipt]
let locale: String
// Only meaningful for the PDF export (date range on the cover page);
// harmless to omit for csv/excel, which ignore unknown fields.
var dateFrom: String?
var dateTo: String?
}
struct ExportResult {
let data: Data
let fileName: String
}
/// - Parameters:
/// - receipts: exactly the receipts to include the backend does not
/// look anything up server-side, it only formats what you send.
/// - locale: `"de"` or `"en"`; anything else falls back to German on
/// the backend, so just always pass one of the two.
static func export(
_ format: Format,
receipts: [Receipt],
locale: String = "de",
dateFrom: String? = nil,
dateTo: String? = nil
) async throws -> ExportResult {
let body = ExportRequestBody(receipts: receipts, locale: locale, dateFrom: dateFrom, dateTo: dateTo)
let request = try APIRequest.json(path: format.path, method: .post, body: body)
let (data, suggestedName) = try await APIClient.shared.sendForFile(request)
let fileName = suggestedName ?? "Belege_Export.\(format.fallbackExtension)"
return ExportResult(data: data, fileName: fileName)
}
}

View File

@@ -0,0 +1,73 @@
import Foundation
import Security
/// Persists the session token issued by `POST /api/auth/login` /
/// `/api/auth/signup` in the iOS Keychain never UserDefaults, which is
/// unencrypted on disk. This is the native counterpart to the web app's
/// httpOnly session cookie (see `src/lib/auth/session.ts`); the backend has
/// no idea which storage a given Bearer token came from.
final class KeychainTokenStore {
static let shared = KeychainTokenStore()
private let service = "app.scan-receipts.ios.session"
private let account = "session-token"
private init() {}
var token: String? {
get { read() }
set {
if let newValue {
save(newValue)
} else {
delete()
}
}
}
private func query(extra: [String: Any] = [:]) -> [String: Any] {
var q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
extra.forEach { q[$0] = $1 }
return q
}
private func read() -> String? {
var query = self.query()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
private func save(_ token: String) {
let data = Data(token.utf8)
// Overwrite-or-insert: a plain SecItemAdd fails with errSecDuplicateItem
// on every login after the first, since the (service, account) pair is
// stable by design.
let attributes: [String: Any] = [
kSecValueData as String: data,
// Available as soon as the device is unlocked once after boot
// matches what a background-launched app (e.g. from a push
// notification) needs, without requiring the passcode be entered
// in *this* unlock cycle the way `WhenUnlocked` would.
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
let updateStatus = SecItemUpdate(query() as CFDictionary, attributes as CFDictionary)
if updateStatus == errSecItemNotFound {
var insert = query()
attributes.forEach { insert[$0] = $1 }
SecItemAdd(insert as CFDictionary, nil)
}
}
private func delete() {
SecItemDelete(query() as CFDictionary)
}
}

View File

@@ -0,0 +1,32 @@
import Foundation
/// `/api/projects` Pro-only folders. `list()` itself is not gated (a
/// downgraded Pro user can still see how their receipts were filed), but
/// create/rename/delete return `402 pro_required` for a free-plan account
/// surface that as a paywall prompt, not a generic error.
enum ProjectsAPI {
static func list() async throws -> [Project] {
let response: ProjectListResponse = try await APIClient.shared.send(
APIRequest(path: "/api/projects", method: .get)
)
return response.projects
}
@discardableResult
static func create(name: String, color: String?) async throws -> Project {
struct Response: Decodable { let success: Bool; let project: Project }
let request = try APIRequest.json(
path: "/api/projects",
method: .post,
body: CreateProjectRequest(name: name, color: color)
)
let response: Response = try await APIClient.shared.send(request)
return response.project
}
static func delete(id: String) async throws {
try await APIClient.shared.sendDiscardingResponse(
APIRequest(path: "/api/projects/\(id)", method: .delete)
)
}
}

View File

@@ -0,0 +1,72 @@
import Foundation
/// `POST /api/scan` response (src/app/api/scan/route.ts). This does NOT
/// persist anything it only runs the AI extraction and hands back the
/// result. The caller must follow up with `ReceiptsAPI.sync(...)` once the
/// user has reviewed/confirmed the receipt, exactly like the web dashboard's
/// scan review save flow.
struct ScanResponse: Decodable {
let success: Bool
let receipt: Receipt?
let receipts: [Receipt]
let pageCount: Int?
let sourcePageCount: Int?
let truncated: Bool?
}
enum ReceiptsAPI {
/// `POST /api/scan` uploads one image/PDF for AI extraction. Requires a
/// verified session (401 `unauthorized` / 403 `email_not_verified`
/// otherwise) and is subject to the account's monthly/daily scan quota
/// (402 `scan_limit_reached` / 429 `daily_scan_limit_reached`) surface
/// those via `APIError.server(code:...)`.
static func scan(fileData: Data, fileName: String, mimeType: String) async throws -> ScanResponse {
let request = APIRequest.multipartFile(
path: "/api/scan",
fileName: fileName,
mimeType: mimeType,
fileData: fileData
)
return try await APIClient.shared.send(request)
}
/// `GET /api/receipts` the signed-in user's receipts, newest first.
/// `includePreview` pulls the base64 preview image inline for each row
/// keep it `false` for list screens and only request it (or fetch a
/// single id) when actually displaying an image.
static func list(limit: Int = 100, includePreview: Bool = false) async throws -> [Receipt] {
var query = [URLQueryItem(name: "limit", value: String(limit))]
if includePreview { query.append(URLQueryItem(name: "includePreview", value: "1")) }
let request = APIRequest(path: "/api/receipts", method: .get, query: query)
let response: ReceiptListResponse = try await APIClient.shared.send(request)
return response.receipts
}
static func get(id: String) async throws -> Receipt? {
let request = APIRequest(
path: "/api/receipts",
method: .get,
query: [URLQueryItem(name: "id", value: id), URLQueryItem(name: "includePreview", value: "1")]
)
let response: ReceiptListResponse = try await APIClient.shared.send(request)
return response.receipts.first
}
/// `POST /api/receipts` upserts one or more receipts (matched by `id`).
/// This is how a scanned-and-reviewed receipt actually gets saved.
@discardableResult
static func sync(_ receipts: [Receipt]) async throws -> ReceiptSyncResponse {
let request = try APIRequest.json(path: "/api/receipts", method: .post, body: receipts)
return try await APIClient.shared.send(request)
}
/// `DELETE /api/receipts?id=...`
static func delete(id: String) async throws {
let request = APIRequest(
path: "/api/receipts",
method: .delete,
query: [URLQueryItem(name: "id", value: id)]
)
try await APIClient.shared.sendDiscardingResponse(request)
}
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
XcodeGen merges this file with the `properties` block in project.yml
(camera/photo-library usage strings, launch screen, etc.) — this file
only needs to hold entries that aren't set there.
-->
<key>CFBundleDevelopmentRegion</key>
<string>de</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>UIRequiresFullScreen</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,25 @@
import Foundation
/// The backend serialises every timestamp with `Date.prototype.toISOString()`,
/// which always includes milliseconds (`...307Z`). `ISO8601DateFormatter`'s
/// default options reject that string, so every date in this app must go
/// through this helper rather than a bare `ISO8601DateFormatter()` using
/// the wrong one is a silent-failure trap, not a compile error.
enum ISO8601 {
private static let withFractional: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static let withoutFractional: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
static func parse(_ string: String?) -> Date? {
guard let string else { return nil }
return withFractional.date(from: string) ?? withoutFractional.date(from: string)
}
}

82
app/ios/project.yml Normal file
View File

@@ -0,0 +1,82 @@
\
# XcodeGen spec — generates ScanReceipts.xcodeproj on macOS.
#
# Why XcodeGen instead of a hand-committed .xcodeproj: the project file is a
# fragile, Xcode-internal plist format tied to UUIDs Xcode itself assigns.
# Generating it from this YAML on the machine that actually builds keeps the
# real project file out of git entirely (see .gitignore) and avoids merge
# conflicts in a format nobody can diff sensibly.
#
# Setup (macOS, once): `brew install xcodegen`
# Generate the project: `cd app/ios && xcodegen generate`
# Then open ScanReceipts.xcodeproj in Xcode.
name: ScanReceipts
options:
bundleIdPrefix: app.scan-receipts
deploymentTarget:
iOS: "17.0"
createIntermediateGroups: true
settings:
base:
SWIFT_VERSION: "5.0"
MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: "1"
IPHONEOS_DEPLOYMENT_TARGET: "17.0"
TARGETED_DEVICE_FAMILY: "1" # iPhone only for v1; add "1,2" once iPad layouts exist.
# Set your own Apple Developer Team ID here (Xcode > Signing & Capabilities
# also works and overrides this locally without touching the checked-in spec).
DEVELOPMENT_TEAM: ""
CODE_SIGN_STYLE: Automatic
targets:
ScanReceipts:
type: application
platform: iOS
sources:
- path: ScanReceipts
excludes:
- "**/.DS_Store"
info:
path: ScanReceipts/Resources/Info.plist
properties:
CFBundleDisplayName: ScanReceipts
UILaunchScreen: {}
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
NSCameraUsageDescription: "ScanReceipts braucht Kamerazugriff, um Belege zu fotografieren und zu digitalisieren."
NSPhotoLibraryUsageDescription: "ScanReceipts braucht Zugriff auf deine Fotos, um bestehende Belegfotos zu importieren."
NSFaceIDUsageDescription: "ScanReceipts kann Face ID nutzen, um die App beim Öffnen zu schützen."
ITSAppUsesNonExemptEncryption: false
# Same three families the web app loads from Google Fonts (see
# src/styles/globals.css) — self-hosted here as the actual OFL font
# files instead of a CDN, since a native app doesn't have a stylesheet
# to load them from. See ScanReceipts/Design/ZenithTypography.swift.
UIAppFonts:
- HankenGrotesk-Variable.ttf
- Inter-Variable.ttf
- JetBrainsMono-Variable.ttf
entitlements:
path: ScanReceipts/Resources/ScanReceipts.entitlements
properties:
com.apple.developer.applesignin:
- Default
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: app.scan-receipts.ios
PRODUCT_NAME: ScanReceipts
dependencies: []
schemes:
ScanReceipts:
build:
targets:
ScanReceipts: all
run:
config: Debug
archive:
config: Release
test:
config: Debug