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