Files
scan-receipts/app/ios/ScanReceipts/Features/Receipts/ReceiptsListView.swift

133 lines
4.8 KiB
Swift

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
}
}
}