133 lines
4.8 KiB
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
|
|
}
|
|
}
|
|
}
|