136 lines
5.1 KiB
Swift
136 lines
5.1 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|