feat: add iOS support and harden receipt scanning
This commit is contained in:
113
app/ios/ScanReceipts/App/AppState.swift
Normal file
113
app/ios/ScanReceipts/App/AppState.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Single source of truth for "who is signed in" — every feature screen reads
|
||||
/// `AppState.shared` (injected via `.environmentObject`) rather than talking
|
||||
/// to `AuthAPI`/`KeychainTokenStore` directly, so there is exactly one place
|
||||
/// that decides what "signed in" means and exactly one place a 401 anywhere
|
||||
/// in the app routes back to the login screen.
|
||||
@MainActor
|
||||
final class AppState: ObservableObject {
|
||||
enum AuthPhase: Equatable {
|
||||
/// Checking the Keychain / pinging `/api/auth/session` on launch.
|
||||
case checking
|
||||
case signedOut
|
||||
case signedIn(User)
|
||||
}
|
||||
|
||||
@Published private(set) var phase: AuthPhase = .checking
|
||||
@Published var lastError: String?
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init() {
|
||||
NotificationCenter.default.publisher(for: .sessionExpired)
|
||||
.sink { [weak self] _ in
|
||||
Task { @MainActor in self?.phase = .signedOut }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
var currentUser: User? {
|
||||
if case .signedIn(let user) = phase { return user }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Call once on app launch. If a token is already in the Keychain, this
|
||||
/// validates it against the server (a locally-stored token could have
|
||||
/// been revoked server-side, e.g. via "sign out everywhere") rather than
|
||||
/// trusting its mere presence.
|
||||
func bootstrap() async {
|
||||
guard KeychainTokenStore.shared.token != nil else {
|
||||
phase = .signedOut
|
||||
return
|
||||
}
|
||||
do {
|
||||
let response = try await AuthAPI.session()
|
||||
phase = response.user.map(AuthPhase.signedIn) ?? .signedOut
|
||||
} catch {
|
||||
phase = .signedOut
|
||||
}
|
||||
}
|
||||
|
||||
func login(email: String, password: String) async {
|
||||
lastError = nil
|
||||
do {
|
||||
let response = try await AuthAPI.login(email: email, password: password)
|
||||
guard let token = response.token else {
|
||||
// Should never happen — X-Client: ios always yields a token
|
||||
// (see src/app/api/auth/login/route.ts) — but fail safe
|
||||
// rather than silently "succeeding" signed out.
|
||||
lastError = "Anmeldung fehlgeschlagen. Bitte erneut versuchen."
|
||||
return
|
||||
}
|
||||
KeychainTokenStore.shared.token = token
|
||||
// The login response only carries a small subset of the user
|
||||
// (see LoginUser) — immediately follow up with /session so the
|
||||
// rest of the app has the full profile (isPro, scan quota, ...).
|
||||
await bootstrap()
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors `login(email:password:)` exactly, just against
|
||||
/// `/api/auth/apple` instead — same token-storage/bootstrap follow-up,
|
||||
/// same fail-safe if a token somehow doesn't come back. `fullName` is
|
||||
/// only ever non-nil the first time this account authorizes the app
|
||||
/// (see `AuthAPI.appleSignIn`).
|
||||
func signInWithApple(identityToken: String, fullName: String?) async {
|
||||
lastError = nil
|
||||
do {
|
||||
let response = try await AuthAPI.appleSignIn(identityToken: identityToken, fullName: fullName)
|
||||
guard let token = response.token else {
|
||||
lastError = "Anmeldung mit Apple fehlgeschlagen. Bitte erneut versuchen."
|
||||
return
|
||||
}
|
||||
KeychainTokenStore.shared.token = token
|
||||
await bootstrap()
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when a confirmation mail was sent (the account still
|
||||
/// needs email verification before it can log in) — matches the web
|
||||
/// signup flow exactly, including its neutral response (see AuthAPI).
|
||||
func signup(name: String?, email: String, password: String) async -> Bool {
|
||||
lastError = nil
|
||||
do {
|
||||
_ = try await AuthAPI.signup(name: name, email: email, password: password)
|
||||
return true
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func logout() async {
|
||||
try? await AuthAPI.logout()
|
||||
KeychainTokenStore.shared.token = nil
|
||||
phase = .signedOut
|
||||
}
|
||||
}
|
||||
30
app/ios/ScanReceipts/App/MainTabView.swift
Normal file
30
app/ios/ScanReceipts/App/MainTabView.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Shown once `AppState.phase == .signedIn`. Each tab's root view is built
|
||||
/// independently (see the Features/ subfolders) and referenced here only by
|
||||
/// its type name — this file is the one integration point between them, so
|
||||
/// none of the three needs to know the other two exist.
|
||||
struct MainTabView: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
// Features/Scan/ScanTabView.swift
|
||||
ScanTabView()
|
||||
.tabItem { Label("Scannen", systemImage: "camera.viewfinder") }
|
||||
|
||||
// Features/Receipts/ReceiptsListView.swift
|
||||
ReceiptsListView()
|
||||
.tabItem { Label("Belege", systemImage: "list.bullet.rectangle") }
|
||||
|
||||
// Features/Settings/SettingsView.swift
|
||||
SettingsView()
|
||||
.tabItem { Label("Konto", systemImage: "person.crop.circle") }
|
||||
}
|
||||
.tint(.zenithBlack)
|
||||
// Solid white tab bar, no translucent system blur — the design
|
||||
// system rejects soft/blurred depth cues in favor of flat surfaces
|
||||
// with a hard 1px edge (the hairline the system automatically draws
|
||||
// at the top of an opaque tab bar stands in for that border here).
|
||||
.toolbarBackground(Color.zenithSurface, for: .tabBar)
|
||||
.toolbarBackground(.visible, for: .tabBar)
|
||||
}
|
||||
}
|
||||
38
app/ios/ScanReceipts/App/RootView.swift
Normal file
38
app/ios/ScanReceipts/App/RootView.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Top-level router: which screen the user sees is entirely a function of
|
||||
/// `AppState.phase`. Nothing else in the app should make navigation
|
||||
/// decisions based on auth state — route through here instead.
|
||||
struct RootView: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch appState.phase {
|
||||
case .checking:
|
||||
LaunchView()
|
||||
case .signedOut:
|
||||
// Provided by Features/Auth/AuthFlowView.swift
|
||||
AuthFlowView()
|
||||
case .signedIn:
|
||||
// Provided by Features/Root/MainTabView.swift
|
||||
MainTabView()
|
||||
}
|
||||
}
|
||||
.animation(.default, value: appState.phase)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LaunchView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: ZenithSpacing.xs) {
|
||||
Image(systemName: "doc.text.viewfinder")
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.zenithBlack)
|
||||
ProgressView()
|
||||
.tint(.zenithBlack)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.zenithBg)
|
||||
}
|
||||
}
|
||||
23
app/ios/ScanReceipts/App/ScanReceiptsApp.swift
Normal file
23
app/ios/ScanReceipts/App/ScanReceiptsApp.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct ScanReceiptsApp: App {
|
||||
@StateObject private var appState = AppState()
|
||||
|
||||
init() {
|
||||
// App-wide tint: the Zenith Silver system uses pure black for every
|
||||
// interactive/structural accent (see Design/ZenithColors.swift) —
|
||||
// there is no separate "brand blue" the way most iOS apps have one.
|
||||
UITabBar.appearance().tintColor = UIColor(Color.zenithBlack)
|
||||
UINavigationBar.appearance().tintColor = UIColor(Color.zenithBlack)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environmentObject(appState)
|
||||
.tint(.zenithBlack)
|
||||
.task { await appState.bootstrap() }
|
||||
}
|
||||
}
|
||||
}
|
||||
137
app/ios/ScanReceipts/Configuration.storekit
Normal file
137
app/ios/ScanReceipts/Configuration.storekit
Normal 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
|
||||
}
|
||||
}
|
||||
96
app/ios/ScanReceipts/Design/ZenithButtonStyle.swift
Normal file
96
app/ios/ScanReceipts/Design/ZenithButtonStyle.swift
Normal 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() }
|
||||
}
|
||||
35
app/ios/ScanReceipts/Design/ZenithCard.swift
Normal file
35
app/ios/ScanReceipts/Design/ZenithCard.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
81
app/ios/ScanReceipts/Design/ZenithColors.swift
Normal file
81
app/ios/ScanReceipts/Design/ZenithColors.swift
Normal 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)
|
||||
}
|
||||
13
app/ios/ScanReceipts/Design/ZenithDivider.swift
Normal file
13
app/ios/ScanReceipts/Design/ZenithDivider.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
24
app/ios/ScanReceipts/Design/ZenithSpacing.swift
Normal file
24
app/ios/ScanReceipts/Design/ZenithSpacing.swift
Normal 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
|
||||
}
|
||||
125
app/ios/ScanReceipts/Design/ZenithStatusBadge.swift
Normal file
125
app/ios/ScanReceipts/Design/ZenithStatusBadge.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
79
app/ios/ScanReceipts/Design/ZenithTextField.swift
Normal file
79
app/ios/ScanReceipts/Design/ZenithTextField.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
93
app/ios/ScanReceipts/Design/ZenithTypography.swift
Normal file
93
app/ios/ScanReceipts/Design/ZenithTypography.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
86
app/ios/ScanReceipts/Features/Auth/AppleSignInButton.swift
Normal file
86
app/ios/ScanReceipts/Features/Auth/AppleSignInButton.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
import AuthenticationServices
|
||||
import Foundation // PersonNameComponentsFormatter
|
||||
|
||||
/// "Mit Apple anmelden" — placed below the email/password form on
|
||||
/// `LoginView`. Wired to the real backend: `POST /api/auth/apple`
|
||||
/// (`src/app/api/auth/apple/route.ts`) verifies the identity token Apple
|
||||
/// hands back and returns a session exactly like `/api/auth/login` does —
|
||||
/// see `AuthAPI.appleSignIn` / `AppState.signInWithApple`.
|
||||
struct AppleSignInButton: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@EnvironmentObject private var appState: AppState
|
||||
|
||||
@State private var isSigningIn = false
|
||||
/// Local, Apple-specific failures (token decode, user cancelled) — kept
|
||||
/// separate from `appState.lastError` so a cancelled Apple sheet doesn't
|
||||
/// show a stale email/password error underneath it, or vice versa.
|
||||
@State private var localErrorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: ZenithSpacing.unit * 2) {
|
||||
SignInWithAppleButton(.signIn) { request in
|
||||
request.requestedScopes = [.fullName, .email]
|
||||
} onCompletion: { result in
|
||||
handle(result)
|
||||
}
|
||||
// Apple's Human Interface Guidelines fix this button's own shape
|
||||
// and colors — not overridable to match Zenith Silver's 0px
|
||||
// corners, and Apple explicitly does not allow restyling it.
|
||||
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.disabled(isSigningIn)
|
||||
.overlay {
|
||||
if isSigningIn {
|
||||
ProgressView().tint(.white)
|
||||
}
|
||||
}
|
||||
|
||||
// A backend-side failure (invalid token, server error, ...) sets
|
||||
// `appState.lastError`, which `LoginView` already renders above
|
||||
// this button — showing it a second time here would just
|
||||
// duplicate the same message. Only a LOCAL, pre-network failure
|
||||
// (decode error, user cancelled) is shown here.
|
||||
if let localErrorMessage {
|
||||
Text(localErrorMessage).zenithBodySmStyle(color: .zenithError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ result: Result<ASAuthorization, Error>) {
|
||||
switch result {
|
||||
case .success(let authorization):
|
||||
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
|
||||
let tokenData = credential.identityToken,
|
||||
let identityToken = String(data: tokenData, encoding: .utf8) else {
|
||||
localErrorMessage = "Apple-Anmeldung fehlgeschlagen. Bitte erneut versuchen."
|
||||
return
|
||||
}
|
||||
localErrorMessage = nil
|
||||
|
||||
// Only present on the account's very first authorization with
|
||||
// this app — nil on every later sign-in, which is expected and
|
||||
// fine (AuthAPI.appleSignIn/the backend both treat it as optional).
|
||||
let fullName = credential.fullName.flatMap { components -> String? in
|
||||
let formatted = PersonNameComponentsFormatter.localizedString(from: components, style: .default)
|
||||
return formatted.isEmpty ? nil : formatted
|
||||
}
|
||||
|
||||
isSigningIn = true
|
||||
Task {
|
||||
await appState.signInWithApple(identityToken: identityToken, fullName: fullName)
|
||||
isSigningIn = false
|
||||
}
|
||||
case .failure(let error):
|
||||
// The user cancelling the Apple Sign-In sheet also lands here
|
||||
// (ASAuthorizationError.canceled) — that's expected, not a
|
||||
// failure worth surfacing as an error.
|
||||
if (error as? ASAuthorizationError)?.code == .canceled {
|
||||
localErrorMessage = nil
|
||||
} else {
|
||||
localErrorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
app/ios/ScanReceipts/Features/Auth/AuthFlowView.swift
Normal file
34
app/ios/ScanReceipts/Features/Auth/AuthFlowView.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Shown for `AppState.phase == .signedOut` (see `App/RootView.swift`, which
|
||||
/// depends on this exact type name and its zero-argument initializer). Owns
|
||||
/// simple in-feature navigation between "Anmelden", "Registrieren", and the
|
||||
/// post-signup "bitte bestätige deine E-Mail" screen — none of that belongs
|
||||
/// in `AppState` since none of it affects whether the user is actually
|
||||
/// signed in; `AppState.phase` only changes once a real session exists.
|
||||
struct AuthFlowView: View {
|
||||
private enum Screen: Equatable {
|
||||
case login
|
||||
case signup
|
||||
case verificationPending(email: String)
|
||||
}
|
||||
|
||||
@State private var screen: Screen = .login
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch screen {
|
||||
case .login:
|
||||
LoginView(onSwitchToSignup: { screen = .signup })
|
||||
case .signup:
|
||||
SignupView(
|
||||
onSignupSucceeded: { email in screen = .verificationPending(email: email) },
|
||||
onSwitchToLogin: { screen = .login }
|
||||
)
|
||||
case .verificationPending(let email):
|
||||
VerificationPendingView(email: email, onGoToLogin: { screen = .login })
|
||||
}
|
||||
}
|
||||
.animation(.default, value: screen)
|
||||
}
|
||||
}
|
||||
109
app/ios/ScanReceipts/Features/Auth/LoginView.swift
Normal file
109
app/ios/ScanReceipts/Features/Auth/LoginView.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
import SwiftUI
|
||||
|
||||
/// "Anmelden" screen — the default screen of `AuthFlowView`. Talks to auth
|
||||
/// state exclusively through `AppState.login(email:password:)`; never calls
|
||||
/// `AuthAPI` directly (see `AppState`'s own doc comment for why).
|
||||
struct LoginView: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
|
||||
/// Switches `AuthFlowView` to the signup screen.
|
||||
var onSwitchToSignup: () -> Void
|
||||
|
||||
@State private var email = ""
|
||||
@State private var password = ""
|
||||
@State private var isSubmitting = false
|
||||
|
||||
private var canSubmit: Bool {
|
||||
!email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& !password.isEmpty
|
||||
&& !isSubmitting
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: ZenithSpacing.md) {
|
||||
VStack(spacing: ZenithSpacing.xs) {
|
||||
Image(systemName: "doc.text.viewfinder")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(Color.zenithBlack)
|
||||
Text("ScanReceipts")
|
||||
.zenithHeadlineLgStyle()
|
||||
}
|
||||
.padding(.top, ZenithSpacing.lg)
|
||||
|
||||
VStack(spacing: ZenithSpacing.sm) {
|
||||
ZenithTextField(
|
||||
label: "E-Mail",
|
||||
text: $email,
|
||||
keyboardType: .emailAddress,
|
||||
textContentType: .username,
|
||||
autocapitalization: .never,
|
||||
autocorrectionDisabled: true
|
||||
)
|
||||
|
||||
ZenithTextField(
|
||||
label: "Passwort",
|
||||
text: $password,
|
||||
isSecure: true,
|
||||
textContentType: .password
|
||||
)
|
||||
}
|
||||
|
||||
if let error = appState.lastError {
|
||||
Text(error)
|
||||
.zenithBodySmStyle(color: .zenithError)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await submit() }
|
||||
} label: {
|
||||
Group {
|
||||
if isSubmitting {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text("Anmelden")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.zenithPrimary)
|
||||
.disabled(!canSubmit)
|
||||
|
||||
HStack(spacing: ZenithSpacing.sm) {
|
||||
ZenithDivider()
|
||||
Text("oder")
|
||||
.zenithBodySmStyle()
|
||||
ZenithDivider()
|
||||
}
|
||||
|
||||
// No outer `.frame(height:)` here — AppleSignInButton sizes
|
||||
// its own button (minHeight: 50) and needs the flexibility
|
||||
// to grow when it shows its own inline error text below it.
|
||||
AppleSignInButton()
|
||||
|
||||
Button("Noch kein Konto? Registrieren") {
|
||||
appState.lastError = nil
|
||||
onSwitchToSignup()
|
||||
}
|
||||
.buttonStyle(.zenithPlain)
|
||||
.padding(.top, ZenithSpacing.xs)
|
||||
}
|
||||
.padding(.horizontal, ZenithSpacing.md)
|
||||
.padding(.bottom, ZenithSpacing.lg)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.background(Color.zenithBg)
|
||||
}
|
||||
|
||||
private func submit() async {
|
||||
guard canSubmit else { return }
|
||||
isSubmitting = true
|
||||
defer { isSubmitting = false }
|
||||
await appState.login(
|
||||
email: email.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
password: password
|
||||
)
|
||||
}
|
||||
}
|
||||
110
app/ios/ScanReceipts/Features/Auth/SignupView.swift
Normal file
110
app/ios/ScanReceipts/Features/Auth/SignupView.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
import SwiftUI
|
||||
|
||||
/// "Registrieren" screen. On success this does NOT sign the user in — the
|
||||
/// backend requires the emailed confirmation link to be opened first, exactly
|
||||
/// like the web signup flow (see `AppState.signup(name:email:password:)`) —
|
||||
/// so success routes to `VerificationPendingView` via `onSignupSucceeded`,
|
||||
/// not into `AppState`.
|
||||
struct SignupView: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
|
||||
var onSignupSucceeded: (_ email: String) -> Void
|
||||
var onSwitchToLogin: () -> Void
|
||||
|
||||
@State private var name = ""
|
||||
@State private var email = ""
|
||||
@State private var password = ""
|
||||
@State private var isSubmitting = false
|
||||
|
||||
private var canSubmit: Bool {
|
||||
!email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& !password.isEmpty
|
||||
&& !isSubmitting
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: ZenithSpacing.md) {
|
||||
Text("Konto erstellen")
|
||||
.zenithHeadlineLgStyle()
|
||||
.padding(.top, ZenithSpacing.lg)
|
||||
|
||||
VStack(spacing: ZenithSpacing.sm) {
|
||||
ZenithTextField(
|
||||
label: "Name (optional)",
|
||||
text: $name,
|
||||
textContentType: .name,
|
||||
autocapitalization: .words
|
||||
)
|
||||
|
||||
ZenithTextField(
|
||||
label: "E-Mail",
|
||||
text: $email,
|
||||
keyboardType: .emailAddress,
|
||||
textContentType: .username,
|
||||
autocapitalization: .never,
|
||||
autocorrectionDisabled: true
|
||||
)
|
||||
|
||||
ZenithTextField(
|
||||
label: "Passwort",
|
||||
text: $password,
|
||||
isSecure: true,
|
||||
textContentType: .newPassword
|
||||
)
|
||||
}
|
||||
|
||||
if let error = appState.lastError {
|
||||
Text(error)
|
||||
.zenithBodySmStyle(color: .zenithError)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await submit() }
|
||||
} label: {
|
||||
Group {
|
||||
if isSubmitting {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text("Konto erstellen")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.zenithPrimary)
|
||||
.disabled(!canSubmit)
|
||||
|
||||
Button("Schon ein Konto? Anmelden") {
|
||||
appState.lastError = nil
|
||||
onSwitchToLogin()
|
||||
}
|
||||
.buttonStyle(.zenithPlain)
|
||||
.padding(.top, ZenithSpacing.xs)
|
||||
}
|
||||
.padding(.horizontal, ZenithSpacing.md)
|
||||
.padding(.bottom, ZenithSpacing.lg)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.background(Color.zenithBg)
|
||||
}
|
||||
|
||||
private func submit() async {
|
||||
guard canSubmit else { return }
|
||||
isSubmitting = true
|
||||
defer { isSubmitting = false }
|
||||
|
||||
let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let succeeded = await appState.signup(
|
||||
name: trimmedName.isEmpty ? nil : trimmedName,
|
||||
email: trimmedEmail,
|
||||
password: password
|
||||
)
|
||||
if succeeded {
|
||||
onSignupSucceeded(trimmedEmail)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Shown after a successful `POST /api/auth/signup` — the account exists but
|
||||
/// is inert until the emailed confirmation link is opened (deliberate
|
||||
/// anti-abuse design, mirrored from the web signup flow — see
|
||||
/// `AppState.signup(name:email:password:)`). This screen never signs the
|
||||
/// user in itself; it just points back to `LoginView` once they've verified
|
||||
/// (on this device or any other) and come back.
|
||||
struct VerificationPendingView: View {
|
||||
let email: String
|
||||
var onGoToLogin: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: ZenithSpacing.md) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "envelope.badge")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(Color.zenithBlack)
|
||||
|
||||
Text("Bitte bestätige deine E-Mail")
|
||||
.zenithHeadlineLgStyle()
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
(
|
||||
Text("Wir haben einen Bestätigungslink an ")
|
||||
+ Text(email).fontWeight(.semibold)
|
||||
+ Text(" gesendet. Öffne den Link auf diesem oder einem anderen Gerät, um dein Konto zu aktivieren, und melde dich anschließend hier an.")
|
||||
)
|
||||
.zenithBodyStyle(color: .zenithMuted)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, ZenithSpacing.xs)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
onGoToLogin()
|
||||
} label: {
|
||||
Text("Zum Login")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.zenithPrimary)
|
||||
}
|
||||
.padding(.horizontal, ZenithSpacing.md)
|
||||
.padding(.vertical, ZenithSpacing.lg)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.zenithBg)
|
||||
}
|
||||
}
|
||||
99
app/ios/ScanReceipts/Features/Receipts/ExportSheet.swift
Normal file
99
app/ios/ScanReceipts/Features/Receipts/ExportSheet.swift
Normal 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 }
|
||||
}
|
||||
507
app/ios/ScanReceipts/Features/Receipts/ReceiptDetailView.swift
Normal file
507
app/ios/ScanReceipts/Features/Receipts/ReceiptDetailView.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
72
app/ios/ScanReceipts/Features/Receipts/ReceiptRow.swift
Normal file
72
app/ios/ScanReceipts/Features/Receipts/ReceiptRow.swift
Normal 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
|
||||
}()
|
||||
}
|
||||
132
app/ios/ScanReceipts/Features/Receipts/ReceiptsListView.swift
Normal file
132
app/ios/ScanReceipts/Features/Receipts/ReceiptsListView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
20
app/ios/ScanReceipts/Features/Receipts/ShareSheet.swift
Normal file
20
app/ios/ScanReceipts/Features/Receipts/ShareSheet.swift
Normal 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.
|
||||
}
|
||||
}
|
||||
65
app/ios/ScanReceipts/Features/Scan/DocumentCameraView.swift
Normal file
65
app/ios/ScanReceipts/Features/Scan/DocumentCameraView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
253
app/ios/ScanReceipts/Features/Scan/ReceiptReviewView.swift
Normal file
253
app/ios/ScanReceipts/Features/Scan/ReceiptReviewView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
135
app/ios/ScanReceipts/Features/Scan/ScanTabView.swift
Normal file
135
app/ios/ScanReceipts/Features/Scan/ScanTabView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
49
app/ios/ScanReceipts/Features/Scan/ScanViewModel.swift
Normal file
49
app/ios/ScanReceipts/Features/Scan/ScanViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
107
app/ios/ScanReceipts/Features/Settings/ChangePasswordView.swift
Normal file
107
app/ios/ScanReceipts/Features/Settings/ChangePasswordView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
121
app/ios/ScanReceipts/Features/Settings/DeleteAccountView.swift
Normal file
121
app/ios/ScanReceipts/Features/Settings/DeleteAccountView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
195
app/ios/ScanReceipts/Features/Settings/PaywallView.swift
Normal file
195
app/ios/ScanReceipts/Features/Settings/PaywallView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
222
app/ios/ScanReceipts/Features/Settings/ProjectsListView.swift
Normal file
222
app/ios/ScanReceipts/Features/Settings/ProjectsListView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
97
app/ios/ScanReceipts/Features/Settings/PurchaseService.swift
Normal file
97
app/ios/ScanReceipts/Features/Settings/PurchaseService.swift
Normal 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 {}
|
||||
}
|
||||
162
app/ios/ScanReceipts/Features/Settings/SettingsView.swift
Normal file
162
app/ios/ScanReceipts/Features/Settings/SettingsView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
26
app/ios/ScanReceipts/Models/Project.swift
Normal file
26
app/ios/ScanReceipts/Models/Project.swift
Normal 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?
|
||||
}
|
||||
148
app/ios/ScanReceipts/Models/Receipt.swift
Normal file
148
app/ios/ScanReceipts/Models/Receipt.swift
Normal 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?
|
||||
}
|
||||
88
app/ios/ScanReceipts/Models/User.swift
Normal file
88
app/ios/ScanReceipts/Models/User.swift
Normal 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?
|
||||
}
|
||||
189
app/ios/ScanReceipts/Networking/APIClient.swift
Normal file
189
app/ios/ScanReceipts/Networking/APIClient.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
30
app/ios/ScanReceipts/Networking/APIEnvironment.swift
Normal file
30
app/ios/ScanReceipts/Networking/APIEnvironment.swift
Normal 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
|
||||
}
|
||||
}
|
||||
78
app/ios/ScanReceipts/Networking/APIError.swift
Normal file
78
app/ios/ScanReceipts/Networking/APIError.swift
Normal 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))."
|
||||
}
|
||||
}
|
||||
}
|
||||
46
app/ios/ScanReceipts/Networking/AccountAPI.swift
Normal file
46
app/ios/ScanReceipts/Networking/AccountAPI.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
95
app/ios/ScanReceipts/Networking/AuthAPI.swift
Normal file
95
app/ios/ScanReceipts/Networking/AuthAPI.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
55
app/ios/ScanReceipts/Networking/ExportAPI.swift
Normal file
55
app/ios/ScanReceipts/Networking/ExportAPI.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
73
app/ios/ScanReceipts/Networking/KeychainTokenStore.swift
Normal file
73
app/ios/ScanReceipts/Networking/KeychainTokenStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
32
app/ios/ScanReceipts/Networking/ProjectsAPI.swift
Normal file
32
app/ios/ScanReceipts/Networking/ProjectsAPI.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
72
app/ios/ScanReceipts/Networking/ReceiptsAPI.swift
Normal file
72
app/ios/ScanReceipts/Networking/ReceiptsAPI.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
BIN
app/ios/ScanReceipts/Resources/Fonts/HankenGrotesk-Variable.ttf
Normal file
BIN
app/ios/ScanReceipts/Resources/Fonts/HankenGrotesk-Variable.ttf
Normal file
Binary file not shown.
BIN
app/ios/ScanReceipts/Resources/Fonts/Inter-Variable.ttf
Normal file
BIN
app/ios/ScanReceipts/Resources/Fonts/Inter-Variable.ttf
Normal file
Binary file not shown.
BIN
app/ios/ScanReceipts/Resources/Fonts/JetBrainsMono-Variable.ttf
Normal file
BIN
app/ios/ScanReceipts/Resources/Fonts/JetBrainsMono-Variable.ttf
Normal file
Binary file not shown.
19
app/ios/ScanReceipts/Resources/Info.plist
Normal file
19
app/ios/ScanReceipts/Resources/Info.plist
Normal 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>
|
||||
10
app/ios/ScanReceipts/Resources/ScanReceipts.entitlements
Normal file
10
app/ios/ScanReceipts/Resources/ScanReceipts.entitlements
Normal 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>
|
||||
25
app/ios/ScanReceipts/Support/ISO8601.swift
Normal file
25
app/ios/ScanReceipts/Support/ISO8601.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user