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,507 @@
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<Receipt.LineItem>) -> 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<String> {
Binding(
get: { draft.merchant.address ?? "" },
set: { draft.merchant.address = $0.isEmpty ? nil : $0 }
)
}
private var taxIdBinding: Binding<String> {
Binding(
get: { draft.merchant.taxId ?? "" },
set: { draft.merchant.taxId = $0.isEmpty ? nil : $0 }
)
}
private var receiptNumberBinding: Binding<String> {
Binding(
get: { draft.receiptNumber ?? "" },
set: { draft.receiptNumber = $0.isEmpty ? nil : $0 }
)
}
/// `draft.totalAmount.value` is a required `Double`, but `ZenithAmountField`
/// takes `Binding<Double?>` 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<Double?> {
Binding<Double?>(
get: { draft.totalAmount.value },
set: { newValue in
if let newValue { draft.totalAmount.value = newValue }
}
)
}
private var occasionBinding: Binding<String> {
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<String> {
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<Date> {
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"
}
}
}