feat: add iOS support and harden receipt scanning
This commit is contained in:
99
app/ios/ScanReceipts/Features/Receipts/ExportSheet.swift
Normal file
99
app/ios/ScanReceipts/Features/Receipts/ExportSheet.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Presented from `ReceiptsListView`'s toolbar. Exports the given receipts as
|
||||
/// CSV, XLSX, or PDF via `ExportAPI`, writes the result to a temp file (so
|
||||
/// the receiving app sees the right filename/extension), and hands it to the
|
||||
/// iOS share sheet.
|
||||
struct ExportSheet: View {
|
||||
let receipts: [Receipt]
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var isExporting = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var shareFile: ShareFile?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: ZenithSpacing.sm) {
|
||||
if receipts.isEmpty {
|
||||
Text("Keine Belege zum Exportieren.")
|
||||
.zenithBodyStyle(color: .zenithMuted)
|
||||
.padding(.top, 40)
|
||||
} else {
|
||||
Text("\(receipts.count) Beleg\(receipts.count == 1 ? "" : "e") exportieren")
|
||||
.zenithHeadlineMdStyle()
|
||||
.padding(.top, ZenithSpacing.md)
|
||||
|
||||
VStack(spacing: ZenithSpacing.xs) {
|
||||
exportButton(title: "Als CSV exportieren", systemImage: "tablecells", format: .csv)
|
||||
exportButton(title: "Als Excel exportieren", systemImage: "tablecells.fill", format: .excel)
|
||||
exportButton(title: "Als PDF exportieren", systemImage: "doc.richtext", format: .pdf)
|
||||
}
|
||||
.padding(.horizontal, ZenithSpacing.sm)
|
||||
}
|
||||
|
||||
if isExporting {
|
||||
ProgressView()
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.zenithBodySmStyle(color: .zenithError)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, ZenithSpacing.sm)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.background(Color.zenithBg.ignoresSafeArea())
|
||||
.navigationTitle("Exportieren")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Fertig") { dismiss() }
|
||||
.buttonStyle(.zenithPlain)
|
||||
}
|
||||
}
|
||||
.sheet(item: $shareFile) { file in
|
||||
ShareSheet(activityItems: [file.url])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exportButton(title: String, systemImage: String, format: ExportAPI.Format) -> some View {
|
||||
Button {
|
||||
Task { await export(format) }
|
||||
} label: {
|
||||
Label(title, systemImage: systemImage)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.zenithPrimary)
|
||||
.disabled(isExporting)
|
||||
}
|
||||
|
||||
private func export(_ format: ExportAPI.Format) async {
|
||||
isExporting = true
|
||||
errorMessage = nil
|
||||
defer { isExporting = false }
|
||||
do {
|
||||
let result = try await ExportAPI.export(format, receipts: receipts, locale: "de")
|
||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(result.fileName)
|
||||
try result.data.write(to: tempURL, options: .atomic)
|
||||
shareFile = ShareFile(url: tempURL)
|
||||
} catch let apiError as APIError {
|
||||
if case .server(let code, _, _) = apiError, code == "pro_required" {
|
||||
errorMessage = "Export ist Pro-Nutzern vorbehalten."
|
||||
} else {
|
||||
errorMessage = apiError.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a temp-file URL so it can drive `.sheet(item:)`.
|
||||
private struct ShareFile: Identifiable {
|
||||
let url: URL
|
||||
var id: String { url.absoluteString }
|
||||
}
|
||||
507
app/ios/ScanReceipts/Features/Receipts/ReceiptDetailView.swift
Normal file
507
app/ios/ScanReceipts/Features/Receipts/ReceiptDetailView.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
72
app/ios/ScanReceipts/Features/Receipts/ReceiptRow.swift
Normal file
72
app/ios/ScanReceipts/Features/Receipts/ReceiptRow.swift
Normal file
@@ -0,0 +1,72 @@
|
||||
import SwiftUI
|
||||
|
||||
/// One row in `ReceiptsListView`: merchant, formatted business date and
|
||||
/// amount, category, and the shared `ZenithStatusBadge` (matching the web
|
||||
/// dashboard's `StatusBadge.tsx`).
|
||||
struct ReceiptRow: View {
|
||||
let receipt: Receipt
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: ZenithSpacing.sm) {
|
||||
VStack(alignment: .leading, spacing: ZenithSpacing.unit) {
|
||||
Text(receipt.merchant.name.isEmpty ? "Unbekannter Händler" : receipt.merchant.name)
|
||||
.zenithBodyStyle()
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text(formattedDate)
|
||||
Text("·")
|
||||
Text(receipt.suggestedCategory.rawValue)
|
||||
}
|
||||
.zenithBodySmStyle()
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: ZenithSpacing.xs)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
Text(formattedAmount)
|
||||
.zenithLabelMdStyle()
|
||||
.fontWeight(.medium)
|
||||
.multilineTextAlignment(.trailing)
|
||||
|
||||
ZenithStatusBadge(receipt)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, ZenithSpacing.xs)
|
||||
}
|
||||
|
||||
// `receipt.date.isoDate` is a plain "YYYY-MM-DD" string (no time/zone
|
||||
// component), so it deliberately does NOT go through ISO8601.parse
|
||||
// (which expects a full timestamp and would just return nil here).
|
||||
private var formattedDate: String {
|
||||
guard let date = Self.isoDateOnlyFormatter.date(from: receipt.date.isoDate) else {
|
||||
return receipt.date.isoDate
|
||||
}
|
||||
return Self.displayDateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private var formattedAmount: String {
|
||||
let code = receipt.currency.isEmpty ? "EUR" : receipt.currency
|
||||
return receipt.totalAmount.value.formatted(.currency(code: code))
|
||||
}
|
||||
|
||||
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 displayDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
formatter.locale = Locale(identifier: "de_DE")
|
||||
formatter.timeZone = TimeZone(identifier: "UTC")
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
132
app/ios/ScanReceipts/Features/Receipts/ReceiptsListView.swift
Normal file
132
app/ios/ScanReceipts/Features/Receipts/ReceiptsListView.swift
Normal file
@@ -0,0 +1,132 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The "Belege" tab root (see `App/MainTabView.swift`, which references this
|
||||
/// type by a zero-arg init). Loads the signed-in user's receipts, supports
|
||||
/// client-side search by merchant name, swipe-to-delete, and exporting the
|
||||
/// currently visible receipts via `ExportSheet`. Owns its own
|
||||
/// `NavigationStack`.
|
||||
struct ReceiptsListView: View {
|
||||
@State private var receipts: [Receipt] = []
|
||||
@State private var searchText = ""
|
||||
@State private var isLoading = false
|
||||
@State private var loadErrorMessage: String?
|
||||
@State private var deleteErrorMessage: String?
|
||||
@State private var showExportSheet = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if receipts.isEmpty && !isLoading && loadErrorMessage == nil {
|
||||
ContentUnavailableView(
|
||||
"Noch keine Belege gescannt.",
|
||||
systemImage: "list.bullet.rectangle"
|
||||
)
|
||||
.foregroundStyle(Color.zenithMuted)
|
||||
} else {
|
||||
receiptsList
|
||||
}
|
||||
}
|
||||
.background(Color.zenithBg.ignoresSafeArea())
|
||||
.navigationTitle("Belege")
|
||||
.searchable(text: $searchText, prompt: "Suchen")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showExportSheet = true
|
||||
} label: {
|
||||
Label("Exportieren", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.disabled(filteredReceipts.isEmpty)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await load()
|
||||
}
|
||||
.refreshable {
|
||||
await load()
|
||||
}
|
||||
.sheet(isPresented: $showExportSheet) {
|
||||
ExportSheet(receipts: filteredReceipts)
|
||||
}
|
||||
.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 ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Client-side filter by merchant name — this is also what gets exported
|
||||
/// via the toolbar button, so exporting after a search exports only the
|
||||
/// filtered set.
|
||||
private var filteredReceipts: [Receipt] {
|
||||
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return receipts }
|
||||
return receipts.filter { $0.merchant.name.localizedCaseInsensitiveContains(trimmed) }
|
||||
}
|
||||
|
||||
private var receiptsList: some View {
|
||||
List {
|
||||
if let loadErrorMessage {
|
||||
Section {
|
||||
Text(loadErrorMessage)
|
||||
.zenithBodySmStyle(color: .zenithError)
|
||||
}
|
||||
.listRowBackground(Color.zenithSurface)
|
||||
.listRowSeparatorTint(Color.zenithBorder)
|
||||
}
|
||||
|
||||
ForEach(filteredReceipts) { receipt in
|
||||
NavigationLink {
|
||||
ReceiptDetailView(receipt: receipt)
|
||||
} label: {
|
||||
ReceiptRow(receipt: receipt)
|
||||
}
|
||||
.listRowBackground(Color.zenithSurface)
|
||||
.listRowSeparatorTint(Color.zenithBorder)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await delete(receipt) }
|
||||
} label: {
|
||||
Label("Löschen", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.zenithListBackground()
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
loadErrorMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
receipts = try await ReceiptsAPI.list()
|
||||
} catch {
|
||||
loadErrorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimistic delete: removes the row immediately, then confirms with the
|
||||
/// server. On failure the row is reinserted at its original position and
|
||||
/// an error alert is shown.
|
||||
private func delete(_ receipt: Receipt) async {
|
||||
guard let index = receipts.firstIndex(where: { $0.id == receipt.id }) else { return }
|
||||
let removed = receipts.remove(at: index)
|
||||
do {
|
||||
try await ReceiptsAPI.delete(id: removed.id)
|
||||
} catch {
|
||||
receipts.insert(removed, at: min(index, receipts.count))
|
||||
deleteErrorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
20
app/ios/ScanReceipts/Features/Receipts/ShareSheet.swift
Normal file
20
app/ios/ScanReceipts/Features/Receipts/ShareSheet.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// Thin `UIViewControllerRepresentable` wrapper around `UIActivityViewController`,
|
||||
/// used by `ExportSheet` to hand an exported file off to Mail, Files,
|
||||
/// AirDrop, etc.
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
let activityItems: [Any]
|
||||
var excludedActivityTypes: [UIActivity.ActivityType]?
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
|
||||
controller.excludedActivityTypes = excludedActivityTypes
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
|
||||
// Nothing to update — the activity items are fixed for the sheet's lifetime.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user