feat: add iOS support and harden receipt scanning

This commit is contained in:
Timo
2026-08-20 23:48:26 +02:00
parent f5e06c32b0
commit cf1b799e1b
107 changed files with 11755 additions and 122 deletions

View File

@@ -0,0 +1,86 @@
import SwiftUI
import AuthenticationServices
import Foundation // PersonNameComponentsFormatter
/// "Mit Apple anmelden" placed below the email/password form on
/// `LoginView`. Wired to the real backend: `POST /api/auth/apple`
/// (`src/app/api/auth/apple/route.ts`) verifies the identity token Apple
/// hands back and returns a session exactly like `/api/auth/login` does
/// see `AuthAPI.appleSignIn` / `AppState.signInWithApple`.
struct AppleSignInButton: View {
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject private var appState: AppState
@State private var isSigningIn = false
/// Local, Apple-specific failures (token decode, user cancelled) kept
/// separate from `appState.lastError` so a cancelled Apple sheet doesn't
/// show a stale email/password error underneath it, or vice versa.
@State private var localErrorMessage: String?
var body: some View {
VStack(spacing: ZenithSpacing.unit * 2) {
SignInWithAppleButton(.signIn) { request in
request.requestedScopes = [.fullName, .email]
} onCompletion: { result in
handle(result)
}
// Apple's Human Interface Guidelines fix this button's own shape
// and colors not overridable to match Zenith Silver's 0px
// corners, and Apple explicitly does not allow restyling it.
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
.frame(maxWidth: .infinity, minHeight: 50)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.disabled(isSigningIn)
.overlay {
if isSigningIn {
ProgressView().tint(.white)
}
}
// A backend-side failure (invalid token, server error, ...) sets
// `appState.lastError`, which `LoginView` already renders above
// this button showing it a second time here would just
// duplicate the same message. Only a LOCAL, pre-network failure
// (decode error, user cancelled) is shown here.
if let localErrorMessage {
Text(localErrorMessage).zenithBodySmStyle(color: .zenithError)
}
}
}
private func handle(_ result: Result<ASAuthorization, Error>) {
switch result {
case .success(let authorization):
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
let tokenData = credential.identityToken,
let identityToken = String(data: tokenData, encoding: .utf8) else {
localErrorMessage = "Apple-Anmeldung fehlgeschlagen. Bitte erneut versuchen."
return
}
localErrorMessage = nil
// Only present on the account's very first authorization with
// this app nil on every later sign-in, which is expected and
// fine (AuthAPI.appleSignIn/the backend both treat it as optional).
let fullName = credential.fullName.flatMap { components -> String? in
let formatted = PersonNameComponentsFormatter.localizedString(from: components, style: .default)
return formatted.isEmpty ? nil : formatted
}
isSigningIn = true
Task {
await appState.signInWithApple(identityToken: identityToken, fullName: fullName)
isSigningIn = false
}
case .failure(let error):
// The user cancelling the Apple Sign-In sheet also lands here
// (ASAuthorizationError.canceled) that's expected, not a
// failure worth surfacing as an error.
if (error as? ASAuthorizationError)?.code == .canceled {
localErrorMessage = nil
} else {
localErrorMessage = error.localizedDescription
}
}
}
}

View File

@@ -0,0 +1,34 @@
import SwiftUI
/// Shown for `AppState.phase == .signedOut` (see `App/RootView.swift`, which
/// depends on this exact type name and its zero-argument initializer). Owns
/// simple in-feature navigation between "Anmelden", "Registrieren", and the
/// post-signup "bitte bestätige deine E-Mail" screen none of that belongs
/// in `AppState` since none of it affects whether the user is actually
/// signed in; `AppState.phase` only changes once a real session exists.
struct AuthFlowView: View {
private enum Screen: Equatable {
case login
case signup
case verificationPending(email: String)
}
@State private var screen: Screen = .login
var body: some View {
Group {
switch screen {
case .login:
LoginView(onSwitchToSignup: { screen = .signup })
case .signup:
SignupView(
onSignupSucceeded: { email in screen = .verificationPending(email: email) },
onSwitchToLogin: { screen = .login }
)
case .verificationPending(let email):
VerificationPendingView(email: email, onGoToLogin: { screen = .login })
}
}
.animation(.default, value: screen)
}
}

View File

@@ -0,0 +1,109 @@
import SwiftUI
/// "Anmelden" screen the default screen of `AuthFlowView`. Talks to auth
/// state exclusively through `AppState.login(email:password:)`; never calls
/// `AuthAPI` directly (see `AppState`'s own doc comment for why).
struct LoginView: View {
@EnvironmentObject private var appState: AppState
/// Switches `AuthFlowView` to the signup screen.
var onSwitchToSignup: () -> Void
@State private var email = ""
@State private var password = ""
@State private var isSubmitting = false
private var canSubmit: Bool {
!email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !password.isEmpty
&& !isSubmitting
}
var body: some View {
ScrollView {
VStack(spacing: ZenithSpacing.md) {
VStack(spacing: ZenithSpacing.xs) {
Image(systemName: "doc.text.viewfinder")
.font(.system(size: 40))
.foregroundStyle(Color.zenithBlack)
Text("ScanReceipts")
.zenithHeadlineLgStyle()
}
.padding(.top, ZenithSpacing.lg)
VStack(spacing: ZenithSpacing.sm) {
ZenithTextField(
label: "E-Mail",
text: $email,
keyboardType: .emailAddress,
textContentType: .username,
autocapitalization: .never,
autocorrectionDisabled: true
)
ZenithTextField(
label: "Passwort",
text: $password,
isSecure: true,
textContentType: .password
)
}
if let error = appState.lastError {
Text(error)
.zenithBodySmStyle(color: .zenithError)
.multilineTextAlignment(.center)
}
Button {
Task { await submit() }
} label: {
Group {
if isSubmitting {
ProgressView()
.tint(.white)
} else {
Text("Anmelden")
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.zenithPrimary)
.disabled(!canSubmit)
HStack(spacing: ZenithSpacing.sm) {
ZenithDivider()
Text("oder")
.zenithBodySmStyle()
ZenithDivider()
}
// No outer `.frame(height:)` here AppleSignInButton sizes
// its own button (minHeight: 50) and needs the flexibility
// to grow when it shows its own inline error text below it.
AppleSignInButton()
Button("Noch kein Konto? Registrieren") {
appState.lastError = nil
onSwitchToSignup()
}
.buttonStyle(.zenithPlain)
.padding(.top, ZenithSpacing.xs)
}
.padding(.horizontal, ZenithSpacing.md)
.padding(.bottom, ZenithSpacing.lg)
}
.scrollDismissesKeyboard(.interactively)
.background(Color.zenithBg)
}
private func submit() async {
guard canSubmit else { return }
isSubmitting = true
defer { isSubmitting = false }
await appState.login(
email: email.trimmingCharacters(in: .whitespacesAndNewlines),
password: password
)
}
}

View File

@@ -0,0 +1,110 @@
import SwiftUI
/// "Registrieren" screen. On success this does NOT sign the user in the
/// backend requires the emailed confirmation link to be opened first, exactly
/// like the web signup flow (see `AppState.signup(name:email:password:)`)
/// so success routes to `VerificationPendingView` via `onSignupSucceeded`,
/// not into `AppState`.
struct SignupView: View {
@EnvironmentObject private var appState: AppState
var onSignupSucceeded: (_ email: String) -> Void
var onSwitchToLogin: () -> Void
@State private var name = ""
@State private var email = ""
@State private var password = ""
@State private var isSubmitting = false
private var canSubmit: Bool {
!email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !password.isEmpty
&& !isSubmitting
}
var body: some View {
ScrollView {
VStack(spacing: ZenithSpacing.md) {
Text("Konto erstellen")
.zenithHeadlineLgStyle()
.padding(.top, ZenithSpacing.lg)
VStack(spacing: ZenithSpacing.sm) {
ZenithTextField(
label: "Name (optional)",
text: $name,
textContentType: .name,
autocapitalization: .words
)
ZenithTextField(
label: "E-Mail",
text: $email,
keyboardType: .emailAddress,
textContentType: .username,
autocapitalization: .never,
autocorrectionDisabled: true
)
ZenithTextField(
label: "Passwort",
text: $password,
isSecure: true,
textContentType: .newPassword
)
}
if let error = appState.lastError {
Text(error)
.zenithBodySmStyle(color: .zenithError)
.multilineTextAlignment(.center)
}
Button {
Task { await submit() }
} label: {
Group {
if isSubmitting {
ProgressView()
.tint(.white)
} else {
Text("Konto erstellen")
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.zenithPrimary)
.disabled(!canSubmit)
Button("Schon ein Konto? Anmelden") {
appState.lastError = nil
onSwitchToLogin()
}
.buttonStyle(.zenithPlain)
.padding(.top, ZenithSpacing.xs)
}
.padding(.horizontal, ZenithSpacing.md)
.padding(.bottom, ZenithSpacing.lg)
}
.scrollDismissesKeyboard(.interactively)
.background(Color.zenithBg)
}
private func submit() async {
guard canSubmit else { return }
isSubmitting = true
defer { isSubmitting = false }
let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let succeeded = await appState.signup(
name: trimmedName.isEmpty ? nil : trimmedName,
email: trimmedEmail,
password: password
)
if succeeded {
onSignupSucceeded(trimmedEmail)
}
}
}

View File

@@ -0,0 +1,49 @@
import SwiftUI
/// Shown after a successful `POST /api/auth/signup` the account exists but
/// is inert until the emailed confirmation link is opened (deliberate
/// anti-abuse design, mirrored from the web signup flow see
/// `AppState.signup(name:email:password:)`). This screen never signs the
/// user in itself; it just points back to `LoginView` once they've verified
/// (on this device or any other) and come back.
struct VerificationPendingView: View {
let email: String
var onGoToLogin: () -> Void
var body: some View {
VStack(spacing: ZenithSpacing.md) {
Spacer()
Image(systemName: "envelope.badge")
.font(.system(size: 48))
.foregroundStyle(Color.zenithBlack)
Text("Bitte bestätige deine E-Mail")
.zenithHeadlineLgStyle()
.multilineTextAlignment(.center)
(
Text("Wir haben einen Bestätigungslink an ")
+ Text(email).fontWeight(.semibold)
+ Text(" gesendet. Öffne den Link auf diesem oder einem anderen Gerät, um dein Konto zu aktivieren, und melde dich anschließend hier an.")
)
.zenithBodyStyle(color: .zenithMuted)
.multilineTextAlignment(.center)
.padding(.horizontal, ZenithSpacing.xs)
Spacer()
Button {
onGoToLogin()
} label: {
Text("Zum Login")
.frame(maxWidth: .infinity)
}
.buttonStyle(.zenithPrimary)
}
.padding(.horizontal, ZenithSpacing.md)
.padding(.vertical, ZenithSpacing.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.zenithBg)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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