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