import SwiftUI import UIKit /// Detail/edit screen for a single receipt, pushed from `ReceiptsListView`. /// Edits a local `draft` copy; "Speichern" pushes it via /// `ReceiptsAPI.sync([draft])`, "Löschen" confirms then calls /// `ReceiptsAPI.delete(id:)` and pops back on success. /// /// The body is deliberately split into small `@ViewBuilder` sections rather /// than one large `Form { ... }` — a single body this size (many Sections, /// Pickers, and conditionals) can make the Swift type-checker choke, and /// there is no compiler available in this environment to catch that. struct ReceiptDetailView: View { @State private var draft: Receipt @Environment(\.dismiss) private var dismiss @State private var isSaving = false @State private var didSave = false @State private var saveErrorMessage: String? @State private var isDeleting = false @State private var showDeleteConfirmation = false @State private var deleteErrorMessage: String? init(receipt: Receipt) { _draft = State(initialValue: receipt) } var body: some View { Form { imageSection merchantSection receiptSection amountSection categorySection paymentMethodSection lineItemsSection notesSection infoSection actionsSection } .zenithListBackground() .navigationTitle(draft.merchant.name.isEmpty ? "Beleg" : draft.merchant.name) .navigationBarTitleDisplayMode(.inline) .task { await loadPreviewIfNeeded() } .alert( "Beleg löschen?", isPresented: $showDeleteConfirmation ) { Button("Abbrechen", role: .cancel) {} Button("Löschen", role: .destructive) { Task { await performDelete() } } } message: { Text("Diese Aktion kann nicht rückgängig gemacht werden.") } .alert( "Löschen fehlgeschlagen", isPresented: Binding( get: { deleteErrorMessage != nil }, set: { isPresented in if !isPresented { deleteErrorMessage = nil } } ) ) { Button("OK", role: .cancel) { deleteErrorMessage = nil } } message: { Text(deleteErrorMessage ?? "") } } // MARK: - Sections @ViewBuilder private var imageSection: some View { if let uiImage = dataURLImage { Section { Image(uiImage: uiImage) .resizable() .scaledToFit() .frame(maxHeight: 260) .frame(maxWidth: .infinity) .overlay(Rectangle().strokeBorder(Color.zenithBorder, lineWidth: 1)) } .listRowBackground(Color.zenithSurface) .listRowSeparatorTint(Color.zenithBorder) } else if let remoteURL = remoteImageURL { Section { remoteImageView(url: remoteURL) } .listRowBackground(Color.zenithSurface) .listRowSeparatorTint(Color.zenithBorder) } } private func remoteImageView(url: URL) -> some View { AsyncImage(url: url) { phase in switch phase { case .success(let image): image .resizable() .scaledToFit() .frame(maxHeight: 260) .frame(maxWidth: .infinity) .overlay(Rectangle().strokeBorder(Color.zenithBorder, lineWidth: 1)) case .empty: ProgressView() .frame(maxWidth: .infinity, minHeight: 120) case .failure: EmptyView() @unknown default: EmptyView() } } } private var merchantSection: some View { Section { ZenithTextField(label: "Name", text: $draft.merchant.name) ZenithTextField(label: "Adresse", text: addressBinding) ZenithTextField(label: "Steuer-ID", text: taxIdBinding) } header: { Text("Händler").zenithLabelCapsStyle() } .listRowBackground(Color.zenithSurface) .listRowSeparatorTint(Color.zenithBorder) } private var receiptSection: some View { Section { DatePicker("Datum", selection: dateBinding, displayedComponents: .date) .zenithBodyStyle() ZenithTextField(label: "Belegnummer", text: receiptNumberBinding) Picker("Belegart", selection: $draft.documentType) { ForEach(Receipt.DocumentType.allCases, id: \.self) { type in Text(type.displayLabel).tag(type) } } .zenithBodyStyle() } header: { Text("Beleg").zenithLabelCapsStyle() } .listRowBackground(Color.zenithSurface) .listRowSeparatorTint(Color.zenithBorder) } private var amountSection: some View { Section { ZenithAmountField(label: "Gesamtbetrag", value: totalAmountBinding) ZenithAmountField(label: "Netto", value: $draft.netAmount) ZenithAmountField(label: "Trinkgeld", value: $draft.tipAmount) ZenithTextField(label: "Währung", text: $draft.currency, autocorrectionDisabled: true) HStack { Text("Gesamt inkl. Trinkgeld").zenithBodyStyle() Spacer() Text(draft.grossWithTip.formatted(.currency(code: currencyCodeOrFallback))) .zenithLabelMdStyle() } } header: { Text("Betrag").zenithLabelCapsStyle() } .listRowBackground(Color.zenithSurface) .listRowSeparatorTint(Color.zenithBorder) } private var categorySection: some View { Section { Picker("Kategorie", selection: $draft.suggestedCategory) { ForEach(Receipt.ReceiptCategory.allCases, id: \.self) { category in Text(category.rawValue).tag(category) } } .zenithBodyStyle() } header: { Text("Kategorie").zenithLabelCapsStyle() } .listRowBackground(Color.zenithSurface) .listRowSeparatorTint(Color.zenithBorder) } private var paymentMethodSection: some View { Section { Picker("Zahlungsart", selection: $draft.paymentMethod) { Text("Keine Angabe").tag(Receipt.PaymentMethod?.none) ForEach(Receipt.PaymentMethod.allCases, id: \.self) { method in Text(method.displayLabel).tag(Receipt.PaymentMethod?.some(method)) } } .zenithBodyStyle() } header: { Text("Zahlungsart").zenithLabelCapsStyle() } .listRowBackground(Color.zenithSurface) .listRowSeparatorTint(Color.zenithBorder) } private var lineItemsSection: some View { Section { ForEach($draft.lineItems) { $item in lineItemRow(item: $item) } .onDelete { offsets in draft.lineItems.remove(atOffsets: offsets) } Button { draft.lineItems.append(Receipt.LineItem(description: "", quantity: 1, price: 0)) } label: { Label("Position hinzufügen", systemImage: "plus") } .buttonStyle(.zenithPlain) } header: { Text("Positionen").zenithLabelCapsStyle() } .listRowBackground(Color.zenithSurface) .listRowSeparatorTint(Color.zenithBorder) } private func lineItemRow(item: Binding) -> some View { VStack(alignment: .leading, spacing: ZenithSpacing.xs) { ZenithTextField(label: "Beschreibung", text: item.description) HStack(spacing: ZenithSpacing.xs) { TextField("Menge", value: item.quantity, format: .number) .keyboardType(.decimalPad) .frame(maxWidth: 70) .zenithLabelMdStyle() Text("×") .zenithBodySmStyle() TextField("Preis", value: item.price, format: .number) .keyboardType(.decimalPad) .zenithLabelMdStyle() } } .padding(.vertical, ZenithSpacing.unit) } private var notesSection: some View { Section { ZenithTextField(label: "Anlass", text: occasionBinding) ZenithTextField(label: "Teilnehmer", text: participantsBinding) } header: { Text("Notizen (Bewirtung)").zenithLabelCapsStyle() } .listRowBackground(Color.zenithSurface) .listRowSeparatorTint(Color.zenithBorder) } @ViewBuilder private var infoSection: some View { Section { if let created = ISO8601.parse(draft.createdAt) { HStack { Text("Erstellt").zenithBodyStyle() Spacer() Text(Self.dateTimeFormatter.string(from: created)).zenithLabelMdStyle() } } if let updated = ISO8601.parse(draft.updatedAt) { HStack { Text("Aktualisiert").zenithBodyStyle() Spacer() Text(Self.dateTimeFormatter.string(from: updated)).zenithLabelMdStyle() } } if draft.validation.needsUserReview { Label(draft.validation.reviewReason ?? "Bitte prüfen.", systemImage: "exclamationmark.triangle.fill") .font(.zenithBodySm) .foregroundStyle(Color.zenithPendingText) .padding(ZenithSpacing.xs) .background(Color.zenithPendingBg) .overlay(Rectangle().strokeBorder(Color.zenithPendingBorder, lineWidth: 1)) } } header: { Text("Info").zenithLabelCapsStyle() } .listRowBackground(Color.zenithSurface) .listRowSeparatorTint(Color.zenithBorder) } @ViewBuilder private var actionsSection: some View { Section { Button { Task { await save() } } label: { if isSaving { ProgressView().tint(.white) } else { Text("Speichern") } } .buttonStyle(.zenithPrimary) .disabled(isSaving) if didSave && saveErrorMessage == nil { HStack { Spacer() Label("Gespeichert.", systemImage: "checkmark.circle") .foregroundStyle(Color.zenithScannedText) Spacer() } } if let saveErrorMessage { Text(saveErrorMessage) .zenithBodySmStyle(color: .zenithError) } Button { showDeleteConfirmation = true } label: { if isDeleting { ProgressView() } else { Text("Löschen") } } .buttonStyle(.zenithSecondaryDestructive) .disabled(isDeleting) } .listRowBackground(Color.zenithSurface) .listRowSeparatorTint(Color.zenithBorder) } // MARK: - Image /// `previewUrl` may be a `data:` URL (from `includePreview=1`) or an /// http(s) URL. This handles the `data:` case; `remoteImageURL` handles /// the other. private var dataURLImage: UIImage? { guard let previewUrl = draft.previewUrl, previewUrl.hasPrefix("data:") else { return nil } guard let commaIndex = previewUrl.firstIndex(of: ",") else { return nil } let base64 = String(previewUrl[previewUrl.index(after: commaIndex)...]) guard let data = Data(base64Encoded: base64) else { return nil } return UIImage(data: data) } private var remoteImageURL: URL? { guard let previewUrl = draft.previewUrl, previewUrl.hasPrefix("http://") || previewUrl.hasPrefix("https://") else { return nil } return URL(string: previewUrl) } /// `ReceiptsAPI.list()` (used by the list screen) is called without /// `includePreview`, so `previewUrl` is typically nil at this point. /// Fetch the single receipt with its preview inlined rather than showing /// a blank image area. Only touches `previewUrl`, so it can't clobber /// any in-progress edit elsewhere on the draft. private func loadPreviewIfNeeded() async { guard draft.previewUrl == nil else { return } if let full = try? await ReceiptsAPI.get(id: draft.id) { draft.previewUrl = full.previewUrl } } // MARK: - Bindings for optional / nested fields private var addressBinding: Binding { Binding( get: { draft.merchant.address ?? "" }, set: { draft.merchant.address = $0.isEmpty ? nil : $0 } ) } private var taxIdBinding: Binding { Binding( get: { draft.merchant.taxId ?? "" }, set: { draft.merchant.taxId = $0.isEmpty ? nil : $0 } ) } private var receiptNumberBinding: Binding { Binding( get: { draft.receiptNumber ?? "" }, set: { draft.receiptNumber = $0.isEmpty ? nil : $0 } ) } /// `draft.totalAmount.value` is a required `Double`, but `ZenithAmountField` /// takes `Binding` — this small proxy adapts it. Mirrors the same /// pattern in `ReceiptReviewView.totalAmountBinding`: a momentarily empty /// field (while retyping) is ignored rather than zeroing out the total. private var totalAmountBinding: Binding { Binding( get: { draft.totalAmount.value }, set: { newValue in if let newValue { draft.totalAmount.value = newValue } } ) } private var occasionBinding: Binding { Binding( get: { draft.hospitality?.occasion ?? "" }, set: { newValue in if draft.hospitality == nil { draft.hospitality = Receipt.Hospitality(occasion: nil, participants: nil) } draft.hospitality?.occasion = newValue.isEmpty ? nil : newValue } ) } private var participantsBinding: Binding { Binding( get: { draft.hospitality?.participants ?? "" }, set: { newValue in if draft.hospitality == nil { draft.hospitality = Receipt.Hospitality(occasion: nil, participants: nil) } draft.hospitality?.participants = newValue.isEmpty ? nil : newValue } ) } /// `draft.date.isoDate` is a plain "YYYY-MM-DD" string (no time/zone /// component) — converted through a UTC-anchored formatter on both sides /// so round-tripping through `DatePicker` can't shift the calendar day. private var dateBinding: Binding { Binding( get: { Self.isoDateOnlyFormatter.date(from: draft.date.isoDate) ?? Date() }, set: { draft.date.isoDate = Self.isoDateOnlyFormatter.string(from: $0) } ) } private var currencyCodeOrFallback: String { draft.currency.isEmpty ? "EUR" : draft.currency } // MARK: - Actions private func save() async { isSaving = true saveErrorMessage = nil didSave = false defer { isSaving = false } do { try await ReceiptsAPI.sync([draft]) didSave = true } catch { saveErrorMessage = error.localizedDescription } } private func performDelete() async { isDeleting = true deleteErrorMessage = nil defer { isDeleting = false } do { try await ReceiptsAPI.delete(id: draft.id) dismiss() } catch { deleteErrorMessage = error.localizedDescription } } // MARK: - Formatting helpers private static let isoDateOnlyFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone(identifier: "UTC") return formatter }() private static let dateTimeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .short formatter.locale = Locale(identifier: "de_DE") return formatter }() } // MARK: - German display labels private extension Receipt.DocumentType { var displayLabel: String { switch self { case .kassenbon: return "Kassenbon" case .rechnung: return "Rechnung" case .tankbeleg: return "Tankbeleg" case .bewirtungsbeleg: return "Bewirtungsbeleg" case .parkticket: return "Parkticket" case .sonstiges: return "Sonstiges" } } } private extension Receipt.PaymentMethod { var displayLabel: String { switch self { case .bar: return "Bar" case .ecKarte: return "EC-Karte" case .kreditkarte: return "Kreditkarte" case .ueberweisung: return "Überweisung" case .paypal: return "PayPal" case .applePay: return "Apple Pay" case .googlePay: return "Google Pay" case .sonstige: return "Sonstige" } } }