feat: add iOS support and harden receipt scanning
This commit is contained in:
65
app/ios/ScanReceipts/Features/Scan/DocumentCameraView.swift
Normal file
65
app/ios/ScanReceipts/Features/Scan/DocumentCameraView.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import SwiftUI
|
||||
import VisionKit
|
||||
|
||||
/// Wraps `VNDocumentCameraViewController` (the system document-scanning
|
||||
/// camera UI) for SwiftUI. Only the first scanned page is used for v1 — good
|
||||
/// enough for a single-receipt photo, and the backend already accepts a
|
||||
/// single image per `/api/scan` call.
|
||||
///
|
||||
/// IMPORTANT: `VNDocumentCameraViewController.isSupported` is `false` on the
|
||||
/// Simulator and on devices without a usable camera. Callers must check that
|
||||
/// before presenting this view and fall back to the photo picker instead —
|
||||
/// this type does not check it itself.
|
||||
struct DocumentCameraView: UIViewControllerRepresentable {
|
||||
/// Called exactly once with the captured page, or `nil` if the user
|
||||
/// cancelled or the scan failed. Never left uncalled, so the caller never
|
||||
/// hangs waiting on a result.
|
||||
var onComplete: (UIImage?) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> VNDocumentCameraViewController {
|
||||
let controller = VNDocumentCameraViewController()
|
||||
controller.delegate = context.coordinator
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: VNDocumentCameraViewController, context: Context) {
|
||||
// No dynamic updates needed.
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onComplete: onComplete)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate {
|
||||
private let onComplete: (UIImage?) -> Void
|
||||
|
||||
init(onComplete: @escaping (UIImage?) -> Void) {
|
||||
self.onComplete = onComplete
|
||||
}
|
||||
|
||||
func documentCameraViewController(
|
||||
_ controller: VNDocumentCameraViewController,
|
||||
didFinishWith scan: VNDocumentCameraScan
|
||||
) {
|
||||
let image = scan.pageCount > 0 ? scan.imageOfPage(at: 0) : nil
|
||||
controller.dismiss(animated: true) {
|
||||
self.onComplete(image)
|
||||
}
|
||||
}
|
||||
|
||||
func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {
|
||||
controller.dismiss(animated: true) {
|
||||
self.onComplete(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func documentCameraViewController(
|
||||
_ controller: VNDocumentCameraViewController,
|
||||
didFailWithError error: Error
|
||||
) {
|
||||
controller.dismiss(animated: true) {
|
||||
self.onComplete(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
253
app/ios/ScanReceipts/Features/Scan/ReceiptReviewView.swift
Normal file
253
app/ios/ScanReceipts/Features/Scan/ReceiptReviewView.swift
Normal file
@@ -0,0 +1,253 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Shown after a successful `/api/scan` call. Lets the user review/correct
|
||||
/// the AI-extracted fields before persisting the receipt via
|
||||
/// `ReceiptsAPI.sync(...)`. Edits are kept in a local `@State` copy so a
|
||||
/// failed save never loses what the user typed.
|
||||
struct ReceiptReviewView: View {
|
||||
@State private var receipt: Receipt
|
||||
|
||||
@State private var isSaving = false
|
||||
@State private var saveErrorMessage: String?
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
init(receipt: Receipt) {
|
||||
_receipt = State(initialValue: receipt)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.zenithBg.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: ZenithSpacing.md) {
|
||||
ZenithStatusBadge(receipt)
|
||||
|
||||
// MARK: Belegdaten (Händler / Datum / Betrag)
|
||||
VStack(alignment: .leading, spacing: ZenithSpacing.sm) {
|
||||
HStack(alignment: .bottom, spacing: ZenithSpacing.xs) {
|
||||
ZenithTextField(label: "Händlername", text: $receipt.merchant.name)
|
||||
confidenceIndicator(receipt.merchant.confidence)
|
||||
}
|
||||
|
||||
ZenithDivider()
|
||||
|
||||
HStack(alignment: .bottom, spacing: ZenithSpacing.xs) {
|
||||
LabeledFieldShell(label: "Datum") {
|
||||
DatePicker("", selection: dateBinding, displayedComponents: [.date])
|
||||
.labelsHidden()
|
||||
.datePickerStyle(.compact)
|
||||
.tint(.zenithBlack)
|
||||
}
|
||||
confidenceIndicator(receipt.date.confidence)
|
||||
}
|
||||
|
||||
ZenithDivider()
|
||||
|
||||
HStack(alignment: .bottom, spacing: ZenithSpacing.xs) {
|
||||
ZenithAmountField(label: "Betrag", value: totalAmountBinding)
|
||||
Text(receipt.currency)
|
||||
.zenithLabelMdStyle(color: .zenithMuted)
|
||||
.padding(.bottom, ZenithSpacing.unit * 2)
|
||||
confidenceIndicator(receipt.totalAmount.confidence)
|
||||
}
|
||||
}
|
||||
.zenithCard()
|
||||
|
||||
// MARK: Kategorie / Zahlungsart
|
||||
VStack(alignment: .leading, spacing: ZenithSpacing.sm) {
|
||||
LabeledFieldShell(label: "Kategorie") {
|
||||
Menu {
|
||||
ForEach(Receipt.ReceiptCategory.allCases, id: \.self) { category in
|
||||
Button(category.rawValue) { receipt.suggestedCategory = category }
|
||||
}
|
||||
} label: {
|
||||
menuLabel(receipt.suggestedCategory.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
ZenithDivider()
|
||||
|
||||
LabeledFieldShell(label: "Zahlungsart") {
|
||||
Menu {
|
||||
Button("Keine Angabe") { receipt.paymentMethod = nil }
|
||||
ForEach(Receipt.PaymentMethod.allCases, id: \.self) { method in
|
||||
Button(method.rawValue) { receipt.paymentMethod = method }
|
||||
}
|
||||
} label: {
|
||||
menuLabel(receipt.paymentMethod?.rawValue ?? "Keine Angabe")
|
||||
}
|
||||
}
|
||||
}
|
||||
.zenithCard()
|
||||
|
||||
// MARK: Positionen
|
||||
VStack(alignment: .leading, spacing: ZenithSpacing.sm) {
|
||||
Text("Positionen").zenithLabelCapsStyle()
|
||||
|
||||
ForEach(receipt.lineItems.indices, id: \.self) { index in
|
||||
if index > 0 { ZenithDivider() }
|
||||
HStack(spacing: ZenithSpacing.xs) {
|
||||
TextField("Beschreibung", text: $receipt.lineItems[index].description)
|
||||
.font(.zenithBodyMd)
|
||||
.foregroundStyle(.zenithText)
|
||||
Spacer(minLength: ZenithSpacing.unit)
|
||||
TextField("Preis", value: $receipt.lineItems[index].price, format: .number)
|
||||
.keyboardType(.decimalPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 72)
|
||||
.zenithLabelMdStyle()
|
||||
Button {
|
||||
receipt.lineItems.remove(at: index)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle")
|
||||
.foregroundStyle(.zenithSubtle)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, ZenithSpacing.unit)
|
||||
}
|
||||
|
||||
Button {
|
||||
receipt.lineItems.append(Receipt.LineItem(description: "", quantity: 1, price: 0))
|
||||
} label: {
|
||||
Label("Position hinzufügen", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.zenithPlain)
|
||||
.padding(.top, ZenithSpacing.unit)
|
||||
}
|
||||
.zenithCard()
|
||||
|
||||
// MARK: Save
|
||||
|
||||
Button {
|
||||
Task { await save() }
|
||||
} label: {
|
||||
if isSaving {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text("Speichern")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.zenithPrimary)
|
||||
.disabled(isSaving)
|
||||
|
||||
if let saveErrorMessage {
|
||||
Text(saveErrorMessage)
|
||||
.zenithBodySmStyle(color: .zenithError)
|
||||
}
|
||||
}
|
||||
.padding(ZenithSpacing.sm)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Beleg prüfen")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
// MARK: - Confidence indicator
|
||||
|
||||
/// Small colored dot + percentage, shown only for fields the backend
|
||||
/// flagged as low-confidence (< 70%) — mirrors what the web dashboard
|
||||
/// highlights for manual review.
|
||||
@ViewBuilder
|
||||
private func confidenceIndicator(_ confidence: Double) -> some View {
|
||||
if confidence < 0.7 {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(Color.zenithPendingDot)
|
||||
.frame(width: 8, height: 8)
|
||||
Text("\(Int((confidence * 100).rounded()))%")
|
||||
.zenithLabelMdStyle(color: .zenithPendingText)
|
||||
}
|
||||
.padding(.bottom, ZenithSpacing.unit * 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Text + chevron shown as a `Menu`'s label, styled to match
|
||||
/// `ZenithTextField`'s body text instead of the system default.
|
||||
private func menuLabel(_ text: String) -> some View {
|
||||
HStack {
|
||||
Text(text).zenithBodyStyle()
|
||||
Spacer()
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.zenithSubtle)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Date <-> String("yyyy-MM-dd") binding
|
||||
|
||||
private static let isoDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(identifier: "UTC")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private var dateBinding: Binding<Date> {
|
||||
Binding<Date>(
|
||||
get: { Self.isoDateFormatter.date(from: receipt.date.isoDate) ?? Date() },
|
||||
set: { newValue in receipt.date.isoDate = Self.isoDateFormatter.string(from: newValue) }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Total amount Double <-> Double? binding (for ZenithAmountField)
|
||||
|
||||
private var totalAmountBinding: Binding<Double?> {
|
||||
Binding<Double?>(
|
||||
get: { receipt.totalAmount.value },
|
||||
set: { newValue in
|
||||
if let newValue { receipt.totalAmount.value = newValue }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func save() async {
|
||||
isSaving = true
|
||||
saveErrorMessage = nil
|
||||
defer { isSaving = false }
|
||||
|
||||
do {
|
||||
try await ReceiptsAPI.sync([receipt])
|
||||
dismiss()
|
||||
} catch let error as APIError {
|
||||
saveErrorMessage = error.localizedDescription
|
||||
} catch {
|
||||
saveErrorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Local layout helper: reproduces `ZenithTextField`'s "label-caps text
|
||||
/// above a hairline-bottom-border field" shell for non-`TextField` controls
|
||||
/// (`DatePicker`, the category/payment `Menu`s) so they read as part of the
|
||||
/// same field system instead of falling back to stock iOS control chrome.
|
||||
private struct LabeledFieldShell<Content: View>: View {
|
||||
let label: String
|
||||
let content: Content
|
||||
|
||||
init(label: String, @ViewBuilder content: () -> Content) {
|
||||
self.label = label
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: ZenithSpacing.unit) {
|
||||
Text(label).zenithLabelCapsStyle()
|
||||
|
||||
content
|
||||
.padding(.vertical, ZenithSpacing.unit * 2)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(Color.zenithBorder)
|
||||
.frame(height: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
135
app/ios/ScanReceipts/Features/Scan/ScanTabView.swift
Normal file
135
app/ios/ScanReceipts/Features/Scan/ScanTabView.swift
Normal file
@@ -0,0 +1,135 @@
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import VisionKit
|
||||
|
||||
/// Root view of the "Scannen" tab (see `App/MainTabView.swift`). Offers two
|
||||
/// entry points into the scan flow — the system document camera and a photo
|
||||
/// library fallback — uploads whichever image the user provides for AI
|
||||
/// extraction via `ScanViewModel`, then pushes `ReceiptReviewView` with the
|
||||
/// result.
|
||||
struct ScanTabView: View {
|
||||
@StateObject private var viewModel = ScanViewModel()
|
||||
|
||||
@State private var isShowingCamera = false
|
||||
@State private var cameraUnsupportedAlertPresented = false
|
||||
@State private var photoPickerItem: PhotosPickerItem?
|
||||
|
||||
@State private var extractedReceipt: Receipt?
|
||||
@State private var isShowingReview = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.zenithBg.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: ZenithSpacing.md) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "camera.viewfinder")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.zenithSubtle)
|
||||
|
||||
Text("Beleg scannen")
|
||||
.zenithHeadlineLgStyle()
|
||||
|
||||
Text("Fotografiere einen Beleg oder wähle ein vorhandenes Foto aus deiner Fotomediathek.")
|
||||
.zenithBodySmStyle()
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, ZenithSpacing.md)
|
||||
|
||||
VStack(spacing: ZenithSpacing.xs) {
|
||||
Button {
|
||||
startCamera()
|
||||
} label: {
|
||||
Label("Kamera", systemImage: "camera")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.zenithPrimary)
|
||||
.disabled(viewModel.isUploading)
|
||||
|
||||
PhotosPicker(selection: $photoPickerItem, matching: .images) {
|
||||
Label("Aus Fotos wählen", systemImage: "photo.on.rectangle")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.zenithSecondary)
|
||||
.disabled(viewModel.isUploading)
|
||||
}
|
||||
.padding(.horizontal, ZenithSpacing.md)
|
||||
.padding(.top, ZenithSpacing.xs)
|
||||
|
||||
if viewModel.isUploading {
|
||||
ProgressView("Beleg wird analysiert …")
|
||||
.tint(.zenithBlack)
|
||||
.zenithBodySmStyle()
|
||||
.padding(.top, ZenithSpacing.xs)
|
||||
}
|
||||
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
Text(errorMessage)
|
||||
.zenithBodySmStyle(color: .zenithError)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, ZenithSpacing.md)
|
||||
.padding(.top, ZenithSpacing.unit)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Scannen")
|
||||
.fullScreenCover(isPresented: $isShowingCamera) {
|
||||
DocumentCameraView { image in
|
||||
isShowingCamera = false
|
||||
guard let image else { return }
|
||||
Task { await processImage(image) }
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.onChange(of: photoPickerItem) { _, newItem in
|
||||
guard let newItem else { return }
|
||||
Task {
|
||||
await handlePickedPhoto(newItem)
|
||||
photoPickerItem = nil
|
||||
}
|
||||
}
|
||||
.alert("Kamera nicht verfügbar", isPresented: $cameraUnsupportedAlertPresented) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Die Dokumentenkamera wird auf diesem Gerät nicht unterstützt. Bitte wähle stattdessen ein Foto aus deiner Fotomediathek.")
|
||||
}
|
||||
.navigationDestination(isPresented: $isShowingReview) {
|
||||
if let extractedReceipt {
|
||||
ReceiptReviewView(receipt: extractedReceipt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startCamera() {
|
||||
if VNDocumentCameraViewController.isSupported {
|
||||
isShowingCamera = true
|
||||
} else {
|
||||
cameraUnsupportedAlertPresented = true
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePickedPhoto(_ item: PhotosPickerItem) async {
|
||||
do {
|
||||
guard let data = try await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) else {
|
||||
viewModel.errorMessage = "Foto konnte nicht geladen werden."
|
||||
return
|
||||
}
|
||||
await processImage(image)
|
||||
} catch {
|
||||
viewModel.errorMessage = "Foto konnte nicht geladen werden."
|
||||
}
|
||||
}
|
||||
|
||||
private func processImage(_ image: UIImage) async {
|
||||
if let receipt = await viewModel.uploadAndExtract(image: image) {
|
||||
extractedReceipt = receipt
|
||||
isShowingReview = true
|
||||
}
|
||||
}
|
||||
}
|
||||
49
app/ios/ScanReceipts/Features/Scan/ScanViewModel.swift
Normal file
49
app/ios/ScanReceipts/Features/Scan/ScanViewModel.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// Drives the "Beleg scannen" flow: takes a captured/picked `UIImage`,
|
||||
/// uploads it to `POST /api/scan` for AI extraction, and hands the resulting
|
||||
/// `Receipt` back to the view. Does NOT persist anything itself — saving
|
||||
/// happens later in `ReceiptReviewView` via `ReceiptsAPI.sync(...)`.
|
||||
@MainActor
|
||||
final class ScanViewModel: ObservableObject {
|
||||
@Published var isUploading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// Converts the image to JPEG and uploads it for extraction.
|
||||
/// Returns the extracted `Receipt` on success, or `nil` on failure
|
||||
/// (in which case `errorMessage` is set to German user-facing text).
|
||||
func uploadAndExtract(image: UIImage) async -> Receipt? {
|
||||
guard let data = image.jpegData(compressionQuality: 0.85) else {
|
||||
errorMessage = "Bild konnte nicht verarbeitet werden."
|
||||
return nil
|
||||
}
|
||||
return await uploadAndExtract(imageData: data)
|
||||
}
|
||||
|
||||
/// Same as above, but takes already-encoded JPEG data directly.
|
||||
func uploadAndExtract(imageData: Data) async -> Receipt? {
|
||||
isUploading = true
|
||||
errorMessage = nil
|
||||
defer { isUploading = false }
|
||||
|
||||
do {
|
||||
let response = try await ReceiptsAPI.scan(
|
||||
fileData: imageData,
|
||||
fileName: "receipt.jpg",
|
||||
mimeType: "image/jpeg"
|
||||
)
|
||||
guard let receipt = response.receipt else {
|
||||
errorMessage = "Es konnten keine Belegdaten erkannt werden."
|
||||
return nil
|
||||
}
|
||||
return receipt
|
||||
} catch let error as APIError {
|
||||
errorMessage = error.localizedDescription
|
||||
return nil
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user