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 { Binding( 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 { Binding( 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: 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) } } } }