feat: add iOS support and harden receipt scanning
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user