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