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 }
|
||||
}
|
||||
Reference in New Issue
Block a user