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