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