Teilen-Weg repariert, Zwischenablage nativ, Sprung in den Tankvorgang (2026.9.2.24/.25)
BEFUND. Die Uebergabe der Teilen-Erweiterung an die App konnte nie
funktionieren: sie legte den Beleg im Gruppen-Container ab, die App suchte ihn
ueber @capacitor/preferences - und dessen iOS-Code liest IMMER
UserDefaults.standard der App, configure({group}) setzt nur ein
Schluessel-Praefix. Falscher Behaelter, falscher Schluessel, und weil ein
fehlender Eintrag der Normalfall ist, meldete nichts einen Fehler.
UMBAU. Die Erweiterung schreibt das PDF als Datei in den Gruppen-Container und
gibt den Pfad im Aufruf mit; die App liest ihn ueber @capacitor/filesystem
(volle file://-Pfade) und loescht ihn danach. Abgeholt ueber getLaunchUrl()
und appUrlOpen - iOS benutzt beide. Nebenbei entfallen die
Base64-Aufblaehung und die 4-MB-Grenze der UserDefaults.
ZWEI WEITERE FEHLER AUF DEMSELBEN WEG. Ein Beleg zu einem bestehenden
Tankvorgang lief in die 20-Sekunden-Frist, obwohl der Server laengst fertig
war. Und beim Duplikat verriet das Backend nicht, welcher Vorgang gemeint ist
- jetzt kommt vorhandener_tank_id mit, und beide Oberflaechen springen
dorthin. Dazu: nach dem Speichern oeffnet sich der neue Vorgang, und waehrend
gelesen wird, steht "Tankbeleg wird verarbeitet ..." mit Zapfsaeule und
Kreisel im Formular.
ZWISCHENABLAGE. navigator.clipboard.read() gibt nur Text, HTML und Bilder
heraus - ein kopiertes PDF ist dort nie zu bekommen. UIPasteboard kennt die
Grenze nicht: ein kleines eigenes Plugin holt es, auch aus Mail (Anhang als
Verweis, ueber pasteboard.urls mit Sicherheitsbereich).
Der native Teil braucht einen Mac-Lauf; ios-teilen-einrichten.mjs spielt beide
Swift-Dateien bei jedem Bau ein.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8338,3 +8338,78 @@ nativ. Steht zur Prüfung beim Eigentümer.
|
||||
`UIPasteboard` oder er verschwindet auf dem Telefon.
|
||||
- **Teilen legt keinen Tankvorgang an** (Abschnitt BQ) — die drei OTA-Teile und der native Umbau
|
||||
stehen weiter aus.
|
||||
|
||||
---
|
||||
|
||||
## BR. Der Teilen-Weg, der nie ankam — und was daran alles hing (2026.9.2.24/.25)
|
||||
|
||||
### Der Befund: falscher Behälter, falscher Schlüssel
|
||||
|
||||
Gemeldet am 02.09.2026: „ich habe am iPhone einen Tankbeleg mit der App geteilt. Dabei öffnet die
|
||||
App" — und danach passiert nichts, es wird kein Tankvorgang angelegt.
|
||||
|
||||
Die Erweiterung legte den Beleg base64-kodiert unter `dm360_geteilter_beleg` in
|
||||
`UserDefaults(suiteName: "group.app.datametric360")` ab. Die App holte ihn mit
|
||||
`@capacitor/preferences` und `configure({ group })` — in der Annahme, das Plugin lese damit die
|
||||
UserDefaults der Gruppe. **Das tut es nicht.** Sein iOS-Code (8.0.1,
|
||||
`ios/Sources/PreferencesPlugin/Preferences.swift`):
|
||||
|
||||
```swift
|
||||
private var defaults: UserDefaults { return UserDefaults.standard } // immer
|
||||
private var prefix: String { group + "." } // Gruppe = nur Präfix
|
||||
```
|
||||
|
||||
`configure({group})` wechselt **nicht den Container**, es setzt ein Schlüssel-Präfix. Gesucht wurde
|
||||
also `group.app.datametric360.dm360_geteilter_beleg` im eigenen Bereich der App, abgelegt war der
|
||||
blanke Schlüssel im Gruppen-Container. Zwei Seiten, die sich nie treffen konnten — und weil ein
|
||||
fehlender Eintrag der Normalfall ist (jeder gewöhnliche App-Start), meldete nichts einen Fehler.
|
||||
|
||||
**Die Lehre:** Bei einem Plugin, das eine Systemgrenze überbrücken soll, den Quelltext lesen, nicht
|
||||
die README. Zwei Zeilen hätten den ganzen Entwurf verhindert.
|
||||
|
||||
### Der Umbau: Datei statt UserDefaults
|
||||
|
||||
Die Erweiterung schreibt das PDF jetzt in den Container der App-Gruppe und gibt den Pfad im Aufruf
|
||||
mit — `datametric360://beleg?pfad=file:///…`. Die App liest ihn über `@capacitor/filesystem`, das
|
||||
volle `file://`-Pfade ausdrücklich unterstützt, und löscht die Datei danach. Abgeholt wird über
|
||||
**`getLaunchUrl()` und `appUrlOpen`**: iOS benutzt beide, je nachdem ob die App lief.
|
||||
|
||||
Zwei Altlasten fallen dabei weg: die Base64-Aufblähung um ein Drittel und die 4-MB-Grenze der
|
||||
UserDefaults (jetzt 24 MB, eine reine Vernunftgrenze).
|
||||
|
||||
### Zwei weitere Fehler auf demselben Weg
|
||||
|
||||
**Ein Beleg zu einem bestehenden Tankvorgang lief in die Zeitüberschreitung.** Die Warteschleife in
|
||||
`Tanken.tsx` wertete nur Ergebnisse mit `tank_id == null` aus; kam eine `tank_id` zurück, drehte sie
|
||||
20 Sekunden weiter und meldete „Der Server hat den Beleg noch nicht zurückgemeldet", obwohl er
|
||||
längst fertig war.
|
||||
|
||||
**Beim Duplikat verriet das Backend nicht, welcher Vorgang gemeint ist.** `belege.py` meldete
|
||||
`{"fehler": "Beleg bereits erfasst", "tank_id": <Eingabe>}` — beim Teilen ist das `null`. Jetzt
|
||||
kommt `vorhandener_tank_id` mit, und beide Oberflächen springen dorthin. Bewusst ein **eigenes
|
||||
Feld**: `tank_id` ist die Eingabe des Aufrufs, und das Panel entscheidet daran, wo es den Fehler
|
||||
anzeigt — eine Umdeutung hätte die Anzeige im Neuanlage-Formular stillschweigend zerstört.
|
||||
|
||||
Dazu, auf Wunsch des Eigentümers: nach dem Speichern öffnet sich der neu angelegte Vorgang (die
|
||||
Liste wird frisch gelesen und der Eintrag am `receipt_key` wiedererkannt — der Dienst gibt keine ID
|
||||
zurück), und während der Server liest, steht „Tankbeleg wird verarbeitet …" mit Zapfsäule und
|
||||
Kreisel im Formular.
|
||||
|
||||
### Zwischenablage: dieselbe Grenze, dieselbe Lösung
|
||||
|
||||
`navigator.clipboard.read()` gibt nur Text, HTML und Bilder heraus — ein kopiertes PDF ist dort
|
||||
**nie** zu bekommen. Der Knopf „Aus Zwischenablage einfügen" endete auf dem Telefon zwangsläufig mit
|
||||
„In der Zwischenablage liegt kein PDF". Keine Lücke im Code, sondern die Grenze der Schnittstelle.
|
||||
|
||||
`UIPasteboard` kennt sie nicht. Ein kleines eigenes Plugin
|
||||
(`native/BelegZwischenablage.swift`, 70 Zeilen) holt das PDF auf zwei Wegen: als Daten unter
|
||||
`com.adobe.pdf`, und — für Mail, das Anhänge oft nur als Verweis kopiert — über `pasteboard.urls`
|
||||
mit Sicherheitsbereich. `ios-teilen-einrichten.mjs` hängt die Datei bei jedem Bau an die
|
||||
Sources-Phase des App-Ziels; das steht **vor** dem Ausstieg für „Erweiterung schon vorhanden",
|
||||
weil `ios/` jederzeit neu erzeugt werden kann.
|
||||
|
||||
### Was davon einen Mac-Lauf braucht
|
||||
|
||||
Alles Native: der Umbau der Übergabe und das Zwischenablage-Plugin. Die drei Teile davor
|
||||
(Backend-Feld, Warteschleife, Anzeige) laufen per OTA. **Rein per OTA ist die Brücke über die
|
||||
App-Gruppe nicht zu reparieren** — egal welchen Weg man nimmt.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// BelegZwischenablage.swift
|
||||
// DataMetric360
|
||||
//
|
||||
// Holt ein PDF aus der System-Zwischenablage — der Weg, den die Web-API
|
||||
// nicht anbietet.
|
||||
//
|
||||
// WARUM ES DAS BRAUCHT
|
||||
// --------------------
|
||||
// `navigator.clipboard.read()` gibt aus Datenschutzgründen nur Text, HTML
|
||||
// und Bilder heraus; ein kopiertes PDF taucht dort NIE auf. Der Knopf „Aus
|
||||
// Zwischenablage einfügen" endete deshalb auf dem Telefon zwangsläufig mit
|
||||
// „In der Zwischenablage liegt kein PDF" (gemeldet vom Eigentümer,
|
||||
// 02.09.2026). Das ist keine Lücke in unserem Code, sondern die Grenze der
|
||||
// Schnittstelle.
|
||||
//
|
||||
// `UIPasteboard` kennt diese Grenze nicht: es gibt beliebige Typen heraus,
|
||||
// auch `com.adobe.pdf`. Der Preis ist derselbe wie im Web — iOS zeigt beim
|
||||
// Zugriff seine eigene Einfügen-Rückfrage, und das ist richtig so.
|
||||
//
|
||||
// ZWEI FORMEN, IN DENEN EIN PDF IN DER ZWISCHENABLAGE LIEGT
|
||||
// ---------------------------------------------------------
|
||||
// 1. Als Daten unter `com.adobe.pdf` — so legen es die meisten Apps ab.
|
||||
// 2. Als Datei-URL — Mail kopiert einen Anhang oft nur als Verweis auf eine
|
||||
// temporäre Kopie. Ohne den zweiten Weg bliebe genau der Fall übrig, für
|
||||
// den der Knopf gedacht ist.
|
||||
//
|
||||
// DIESE DATEI IST VERSIONIERT, ios/ NICHT.
|
||||
// `npx cap add ios` erzeugt den nativen Ordner neu und würde alles darin
|
||||
// verlieren. scripts/ios-teilen-einrichten.mjs kopiert sie bei jedem Bau
|
||||
// wieder hinein und hängt sie an die Sources-Phase des App-Ziels.
|
||||
//
|
||||
|
||||
import Capacitor
|
||||
import Foundation
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@objc(BelegZwischenablagePlugin)
|
||||
public class BelegZwischenablagePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "BelegZwischenablagePlugin"
|
||||
public let jsName = "BelegZwischenablage"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "pdfHolen", returnType: CAPPluginReturnPromise)
|
||||
]
|
||||
|
||||
/// Liefert `{ base64, name }`, wenn ein PDF in der Zwischenablage liegt —
|
||||
/// sonst ein leeres Ergebnis. Bewusst kein Fehler: „nichts kopiert" ist
|
||||
/// kein Fehlschlag, sondern eine Antwort, und die Oberfläche sagt dazu
|
||||
/// ohnehin das Passende (zwischenablage.ts).
|
||||
@objc func pdfHolen(_ call: CAPPluginCall) {
|
||||
let ablage = UIPasteboard.general
|
||||
|
||||
if let daten = ablage.data(forPasteboardType: UTType.pdf.identifier) {
|
||||
return call.resolve(["base64": daten.base64EncodedString(), "name": "beleg.pdf"])
|
||||
}
|
||||
|
||||
// Mail legt den Anhang oft nur als Verweis ab.
|
||||
for url in ablage.urls ?? [] where url.pathExtension.lowercased() == "pdf" {
|
||||
// Sicherheitsbereich anfordern: die URL zeigt auf eine Datei
|
||||
// außerhalb des eigenen Sandkastens, sonst schlägt das Lesen fehl.
|
||||
let erlaubt = url.startAccessingSecurityScopedResource()
|
||||
defer { if erlaubt { url.stopAccessingSecurityScopedResource() } }
|
||||
if let daten = try? Data(contentsOf: url) {
|
||||
return call.resolve([
|
||||
"base64": daten.base64EncodedString(),
|
||||
"name": url.lastPathComponent,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
call.resolve([:])
|
||||
}
|
||||
}
|
||||
@@ -11,24 +11,28 @@
|
||||
// Sie kann der App nichts direkt geben; der einzige gemeinsame Boden ist
|
||||
// eine App-Gruppe. Der Weg hier:
|
||||
//
|
||||
// 1. Das PDF wird gelesen und Base64-kodiert.
|
||||
// 2. Es landet zusammen mit seinem Namen in den geteilten
|
||||
// UserDefaults der Gruppe `group.app.datametric360`.
|
||||
// 3. Die Erweiterung öffnet die App über ihr eigenes URL-Schema.
|
||||
// 4. Die App liest den Eintrag beim Start bzw. beim Zurückkehren aus,
|
||||
// löscht ihn und lädt den Beleg hoch (daten/geteilterBeleg.ts).
|
||||
// 1. Das PDF wird in den Container der App-Gruppe geschrieben.
|
||||
// 2. Die Erweiterung öffnet die App über ihr URL-Schema und gibt den
|
||||
// Pfad mit: `datametric360://beleg?pfad=<file://…>`.
|
||||
// 3. Die App liest die Datei über @capacitor/filesystem (das volle
|
||||
// file://-Pfade ausdrücklich unterstützt) und löscht sie danach.
|
||||
//
|
||||
// Base64 in UserDefaults statt einer Datei im Gruppen-Container: die App
|
||||
// liest UserDefaults ohnehin schon über @capacitor/preferences, das genau
|
||||
// diese Gruppe kennt. Eine Datei im Container bräuchte dagegen eigenen
|
||||
// nativen Code auf der App-Seite — ein zusätzliches Plugin für einen Weg,
|
||||
// den es sonst nicht gäbe.
|
||||
// DER ERSTE ENTWURF GING ÜBER USERDEFAULTS UND HAT NIE FUNKTIONIERT.
|
||||
// Er legte das PDF base64-kodiert unter `dm360_geteilter_beleg` in
|
||||
// `UserDefaults(suiteName: gruppe)` ab, weil die App UserDefaults über
|
||||
// @capacitor/preferences ohnehin liest. Nur liest dieses Plugin (8.0.1,
|
||||
// ios/Sources/PreferencesPlugin/Preferences.swift) IMMER
|
||||
// `UserDefaults.standard` der App — `configure({ group })` wechselt nicht
|
||||
// den Container, sondern setzt nur ein Schlüssel-Präfix. Die App suchte
|
||||
// also `group.app.datametric360.dm360_geteilter_beleg` in ihrem eigenen
|
||||
// Bereich, während hier der plain Schlüssel im Gruppen-Container lag:
|
||||
// falscher Behälter und falscher Schlüssel. Gemeldet am 02.09.2026
|
||||
// („es wird kein Tankvorgang angelegt beim Teilen mit der App“).
|
||||
//
|
||||
// Der Preis ist eine Größengrenze: UserDefaults ist für Einstellungen
|
||||
// gedacht, nicht für Anhänge. GRENZE_BYTES hält das bei 4 MB. Ein
|
||||
// Tankbeleg liegt bei einigen Dutzend Kilobyte, ein gescanntes Mehrseiten-
|
||||
// PDF kann darüber liegen — dann sagt die Erweiterung das und der Weg über
|
||||
// „Datei auswählen" in der App bleibt.
|
||||
// Die Datei hat zwei weitere Vorteile: keine Base64-Aufblähung um ein
|
||||
// Drittel, und keine 4-MB-Grenze — UserDefaults ist für Einstellungen
|
||||
// gedacht, nicht für Anhänge. Geblieben ist eine Obergrenze, aber eine
|
||||
// großzügige (GRENZE_BYTES).
|
||||
//
|
||||
// DIESE DATEI IST VERSIONIERT, ios/ NICHT.
|
||||
// `npx cap add ios` erzeugt den nativen Ordner neu und würde alles darin
|
||||
@@ -45,13 +49,13 @@ class ShareViewController: UIViewController {
|
||||
/// Muss zur App-Gruppe in beiden Entitlements-Dateien passen.
|
||||
private let gruppe = "group.app.datametric360"
|
||||
|
||||
/// Schlüssel, unter dem die App den Beleg abholt (geteilterBeleg.ts).
|
||||
private let schluessel = "dm360_geteilter_beleg"
|
||||
/// Unterordner im Gruppen-Container, in dem geteilte Belege liegen.
|
||||
private let ordner = "geteilte-belege"
|
||||
|
||||
/// 4 MB. UserDefaults ist kein Dateispeicher — darüber wird abgelehnt,
|
||||
/// statt das System mit einem Anhang zu belasten, für den es nicht
|
||||
/// gedacht ist.
|
||||
private let grenzeBytes = 4 * 1024 * 1024
|
||||
/// 24 MB. Kein technisches Limit mehr, sondern eine Vernunftgrenze:
|
||||
/// ein Tankbeleg liegt bei einigen Dutzend Kilobyte, alles jenseits
|
||||
/// davon ist kein Beleg, sondern ein Versehen.
|
||||
private let grenzeBytes = 24 * 1024 * 1024
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
@@ -102,26 +106,30 @@ class ShareViewController: UIViewController {
|
||||
}
|
||||
|
||||
private func ablegen(daten: Data, name: String) {
|
||||
guard let ablage = UserDefaults(suiteName: gruppe) else {
|
||||
guard
|
||||
let container = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: gruppe)
|
||||
else {
|
||||
return melden("Die App-Gruppe ist nicht eingerichtet.")
|
||||
}
|
||||
// Form wie in geteilterBeleg.ts erwartet. @capacitor/preferences legt
|
||||
// Werte als String ab, deshalb JSON und kein Dictionary.
|
||||
let inhalt: [String: Any] = [
|
||||
"name": name,
|
||||
"base64": daten.base64EncodedString(),
|
||||
"zeit": Int(Date().timeIntervalSince1970 * 1000),
|
||||
]
|
||||
guard
|
||||
let json = try? JSONSerialization.data(withJSONObject: inhalt),
|
||||
let text = String(data: json, encoding: .utf8)
|
||||
else {
|
||||
|
||||
let ziel = container.appendingPathComponent(ordner, isDirectory: true)
|
||||
// Ein Zeitstempel im Namen: zwei rasch nacheinander geteilte Belege
|
||||
// sollen sich nicht gegenseitig überschreiben, und der Name bleibt
|
||||
// für den Nutzer lesbar, falls er den Container je zu sehen bekommt.
|
||||
let datei = ziel.appendingPathComponent(
|
||||
"\(Int(Date().timeIntervalSince1970))-\(name)")
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: ziel, withIntermediateDirectories: true)
|
||||
try daten.write(to: datei, options: .atomic)
|
||||
} catch {
|
||||
return melden("Der Beleg ließ sich nicht übergeben.")
|
||||
}
|
||||
ablage.set(text, forKey: schluessel)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.appOeffnen()
|
||||
self?.appOeffnen(pfad: datei)
|
||||
self?.abschliessen()
|
||||
}
|
||||
}
|
||||
@@ -129,8 +137,15 @@ class ShareViewController: UIViewController {
|
||||
/// Öffnet die Host-App. Eine Erweiterung darf `UIApplication.shared` nicht
|
||||
/// benutzen, deshalb der Umweg über die Responder-Kette — der einzige von
|
||||
/// Apple vorgesehene Weg aus einer Erweiterung heraus.
|
||||
private func appOeffnen() {
|
||||
guard let ziel = URL(string: "datametric360://beleg") else { return }
|
||||
private func appOeffnen(pfad: URL) {
|
||||
// Der Pfad wandert als Abfrageparameter mit. Er zeigt in den
|
||||
// Gruppen-Container, den die App mit derselben Berechtigung lesen
|
||||
// darf - kein zusätzliches Plugin nötig.
|
||||
var bau = URLComponents()
|
||||
bau.scheme = "datametric360"
|
||||
bau.host = "beleg"
|
||||
bau.queryItems = [URLQueryItem(name: "pfad", value: pfad.absoluteString)]
|
||||
guard let ziel = bau.url else { return }
|
||||
var antwort: UIResponder? = self
|
||||
while let r = antwort {
|
||||
if let app = r as? UIApplication {
|
||||
|
||||
@@ -87,6 +87,10 @@ for (const datei of ["ShareViewController.swift", "Info.plist", `${ZIEL_NAME}.en
|
||||
copyFileSync(join(quelleErw, datei), join(zielErw, datei))
|
||||
}
|
||||
copyFileSync(join(APP, "native", "App.entitlements"), join(iosApp, "App", "App.entitlements"))
|
||||
copyFileSync(
|
||||
join(APP, "native", "BelegZwischenablage.swift"),
|
||||
join(iosApp, "App", "BelegZwischenablage.swift"),
|
||||
)
|
||||
melden(`Quellen nach ios/App/${ZIEL_NAME}/ kopiert`)
|
||||
|
||||
/* ------------------------------------------------------------- URL-Schema */
|
||||
@@ -125,6 +129,29 @@ if (!appPlist.includes("CFBundleURLTypes")) {
|
||||
const projekt = xcode.project(pbxPfad)
|
||||
projekt.parseSync()
|
||||
|
||||
/* ------------------------------------------- Zwischenablage-Plugin (App-Ziel)
|
||||
|
||||
Steht VOR dem Ausstieg unten: die Erweiterung ist nach dem ersten Lauf
|
||||
vorhanden, das Plugin muss trotzdem bei jedem Bau geprüft werden - ios/
|
||||
wird von `npx cap add ios` jederzeit neu erzeugt.
|
||||
|
||||
`addSourceFile` ohne Gruppe hängt die Datei an die ERSTE Sources-Phase des
|
||||
Projekts, und das ist die des App-Ziels - genau die richtige. (Mit Gruppe
|
||||
landete die Datei nur im Navigator; daran ist am 02.09.2026 die Erweiterung
|
||||
ohne Programm entstanden, siehe unten.) */
|
||||
const PLUGIN_DATEI = "BelegZwischenablage.swift"
|
||||
const schonDrin = JSON.stringify(projekt.pbxSourcesBuildPhaseObj()).includes(PLUGIN_DATEI)
|
||||
if (schonDrin) {
|
||||
melden("Zwischenablage-Plugin war bereits im App-Ziel")
|
||||
} else {
|
||||
projekt.addSourceFile(`App/${PLUGIN_DATEI}`)
|
||||
writeFileSync(pbxPfad, projekt.writeSync())
|
||||
melden("Zwischenablage-Plugin ins App-Ziel gehängt")
|
||||
// Nach dem Schreiben neu einlesen: der Rest des Skripts arbeitet sonst auf
|
||||
// einem Stand, der die eigene Änderung nicht kennt.
|
||||
projekt.parseSync()
|
||||
}
|
||||
|
||||
const vorhanden = Object.values(projekt.pbxNativeTargetSection()).some(
|
||||
(z) => z && typeof z === "object" && z.name === ZIEL_NAME,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { App as CapacitorApp } from "@capacitor/app"
|
||||
|
||||
import { DataMetricApi, zugangLesen } from "./api"
|
||||
import { DatenAnbieter, useDaten } from "./daten/DatenKontext"
|
||||
import { geteiltenBelegAbholen, geteiltenBelegSetzen } from "./daten/geteilterBeleg"
|
||||
import { geteiltenBelegLesen, geteiltenBelegSetzen } from "./daten/geteilterBeleg"
|
||||
import { startklarMelden } from "./daten/ota"
|
||||
import { Shell } from "./Shell"
|
||||
import type { Route, SeitenName } from "./navigation"
|
||||
@@ -91,16 +91,27 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
|
||||
useEffect(() => {
|
||||
let abgemeldet = false
|
||||
|
||||
const holen = async () => {
|
||||
const datei = await geteiltenBelegAbholen()
|
||||
/* Der Beleg kommt im Aufruf mit: die Teilen-Erweiterung schreibt ihn in
|
||||
den Container der App-Gruppe und öffnet die App mit dem Pfad
|
||||
(ShareViewController.swift). Zwei Wege hierher, weil iOS beide benutzt:
|
||||
war die App zu, steht der Aufruf in getLaunchUrl(); lief sie schon,
|
||||
kommt er als appUrlOpen.
|
||||
|
||||
Bis zum 02.09.2026 stand hier "resume" plus ein Griff in die
|
||||
UserDefaults - der konnte nie etwas finden, die Begründung steht in
|
||||
geteilterBeleg.ts. */
|
||||
const holen = async (url: string | null | undefined) => {
|
||||
const datei = await geteiltenBelegLesen(url)
|
||||
if (abgemeldet || !datei) return
|
||||
geteiltenBelegSetzen(datei)
|
||||
geheZu("fuel")
|
||||
}
|
||||
|
||||
void holen()
|
||||
void CapacitorApp.getLaunchUrl().then((start) => holen(start?.url))
|
||||
|
||||
const horcher = CapacitorApp.addListener("resume", () => void holen())
|
||||
const horcher = CapacitorApp.addListener("appUrlOpen", (ereignis) => {
|
||||
void holen(ereignis.url)
|
||||
})
|
||||
return () => {
|
||||
abgemeldet = true
|
||||
void horcher.then((h) => h.remove())
|
||||
|
||||
@@ -1,95 +1,119 @@
|
||||
/**
|
||||
* Die Auswertung eines geteilten Belegs — der Teil, der ohne Gerät prüfbar
|
||||
* ist. Das Ablegen selbst passiert nativ (ShareViewController.swift) und
|
||||
* kann hier nicht nachgestellt werden; geprüft wird, was die App mit dem
|
||||
* abgelegten Eintrag macht.
|
||||
* ist. Das Ablegen selbst passiert nativ (ShareViewController.swift) und kann
|
||||
* hier nicht nachgestellt werden; geprüft wird, was die App mit dem Aufruf und
|
||||
* der abgelegten Datei macht.
|
||||
*
|
||||
* `@capacitor/core` und `@capacitor/preferences` werden ersetzt: das eine
|
||||
* meldet sonst „kein natives Gerät" und die Abholung steigt sofort aus, das
|
||||
* andere greift auf eine native Ablage zu, die es hier nicht gibt.
|
||||
* `@capacitor/core` und `@capacitor/filesystem` werden ersetzt: das eine
|
||||
* meldet sonst „kein natives Gerät" und das Lesen steigt sofort aus, das
|
||||
* andere greift auf einen Dateibereich zu, den es hier nicht gibt.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const ablage = new Map<string, string>()
|
||||
const dateien = new Map<string, string>()
|
||||
|
||||
vi.mock("@capacitor/core", () => ({
|
||||
Capacitor: { isNativePlatform: () => true },
|
||||
}))
|
||||
|
||||
vi.mock("@capacitor/preferences", () => ({
|
||||
Preferences: {
|
||||
configure: vi.fn(async () => {}),
|
||||
get: vi.fn(async ({ key }: { key: string }) => ({ value: ablage.get(key) ?? null })),
|
||||
remove: vi.fn(async ({ key }: { key: string }) => {
|
||||
ablage.delete(key)
|
||||
vi.mock("@capacitor/filesystem", () => ({
|
||||
Filesystem: {
|
||||
readFile: vi.fn(async ({ path }: { path: string }) => {
|
||||
const inhalt = dateien.get(path)
|
||||
if (inhalt === undefined) throw new Error("nicht gefunden")
|
||||
return { data: inhalt }
|
||||
}),
|
||||
deleteFile: vi.fn(async ({ path }: { path: string }) => {
|
||||
dateien.delete(path)
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
const SCHLUESSEL = "dm360_geteilter_beleg"
|
||||
|
||||
/** Ein winziges, gültiges PDF als Base64 — Inhalt ist hier gleichgültig. */
|
||||
/** Ein winziges, gültiges PDF als Base64 — der Inhalt ist hier gleichgültig. */
|
||||
const PDF_BASE64 = btoa("%PDF-1.4\n%%EOF\n")
|
||||
|
||||
function ablegen(eintrag: Record<string, unknown>) {
|
||||
ablage.set(SCHLUESSEL, JSON.stringify(eintrag))
|
||||
}
|
||||
const PFAD = "file:///var/mobile/Shared/AppGroup/abc/geteilte-belege/1756832400-beleg.pdf"
|
||||
const AUFRUF = `datametric360://beleg?pfad=${encodeURIComponent(PFAD)}`
|
||||
|
||||
describe("geteilten Beleg abholen", () => {
|
||||
describe("Belegpfad aus dem Aufruf", () => {
|
||||
it("erkennt den Pfad", async () => {
|
||||
const { belegpfadAusUrl } = await import("./geteilterBeleg")
|
||||
expect(belegpfadAusUrl(AUFRUF)).toBe(PFAD)
|
||||
})
|
||||
|
||||
it("lässt fremde Aufrufe in Ruhe", async () => {
|
||||
const { belegpfadAusUrl } = await import("./geteilterBeleg")
|
||||
expect(belegpfadAusUrl("datametric360://einstellungen")).toBeNull()
|
||||
expect(belegpfadAusUrl("https://example.org/?pfad=x")).toBeNull()
|
||||
expect(belegpfadAusUrl(null)).toBeNull()
|
||||
})
|
||||
|
||||
it("verträgt einen Aufruf ohne Pfad", async () => {
|
||||
const { belegpfadAusUrl } = await import("./geteilterBeleg")
|
||||
expect(belegpfadAusUrl("datametric360://beleg")).toBeNull()
|
||||
expect(belegpfadAusUrl("datametric360://beleg?anderes=1")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("geteilten Beleg lesen", () => {
|
||||
beforeEach(() => {
|
||||
ablage.clear()
|
||||
dateien.clear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it("liefert nichts, wenn keiner wartet", async () => {
|
||||
const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
|
||||
expect(await geteiltenBelegAbholen()).toBeNull()
|
||||
it("liefert nichts ohne Aufruf", async () => {
|
||||
const { geteiltenBelegLesen } = await import("./geteilterBeleg")
|
||||
expect(await geteiltenBelegLesen(null)).toBeNull()
|
||||
})
|
||||
|
||||
it("baut aus dem Eintrag eine PDF-Datei", async () => {
|
||||
ablegen({ name: "tankbeleg.pdf", base64: PDF_BASE64, zeit: Date.now() })
|
||||
const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
|
||||
|
||||
const datei = await geteiltenBelegAbholen()
|
||||
|
||||
it("liest die Datei und gibt sie als PDF zurück", async () => {
|
||||
dateien.set(PFAD, PDF_BASE64)
|
||||
const { geteiltenBelegLesen } = await import("./geteilterBeleg")
|
||||
const datei = await geteiltenBelegLesen(AUFRUF)
|
||||
expect(datei).not.toBeNull()
|
||||
expect(datei!.name).toBe("tankbeleg.pdf")
|
||||
expect(datei!.type).toBe("application/pdf")
|
||||
expect(datei!.size).toBeGreaterThan(0)
|
||||
expect(datei?.type).toBe("application/pdf")
|
||||
expect(datei?.name).toBe("1756832400-beleg.pdf")
|
||||
expect(datei?.size).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("räumt den Eintrag weg, damit derselbe Beleg nicht zweimal ankommt", async () => {
|
||||
ablegen({ name: "a.pdf", base64: PDF_BASE64, zeit: Date.now() })
|
||||
const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
|
||||
|
||||
expect(await geteiltenBelegAbholen()).not.toBeNull()
|
||||
expect(await geteiltenBelegAbholen()).toBeNull()
|
||||
/* Der Grund, warum überhaupt gelöscht wird: sonst käme derselbe Beleg bei
|
||||
jedem Öffnen erneut, und weil der Upload im Backend einen Vorgang anlegt,
|
||||
wären das echte Doppeleinträge. */
|
||||
it("räumt die Datei weg, holt sie also genau einmal", async () => {
|
||||
dateien.set(PFAD, PDF_BASE64)
|
||||
const { geteiltenBelegLesen } = await import("./geteilterBeleg")
|
||||
expect(await geteiltenBelegLesen(AUFRUF)).not.toBeNull()
|
||||
expect(dateien.has(PFAD)).toBe(false)
|
||||
expect(await geteiltenBelegLesen(AUFRUF)).toBeNull()
|
||||
})
|
||||
|
||||
it("verwirft einen liegengebliebenen Eintrag", async () => {
|
||||
// Älter als zehn Minuten: das Öffnen der App aus der Erweiterung heraus
|
||||
// ist damals fehlgeschlagen. Ihn jetzt als neuen Beleg zu behandeln wäre
|
||||
// eine Überraschung, keine Hilfe.
|
||||
ablegen({ name: "alt.pdf", base64: PDF_BASE64, zeit: Date.now() - 11 * 60 * 1000 })
|
||||
const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
|
||||
|
||||
expect(await geteiltenBelegAbholen()).toBeNull()
|
||||
})
|
||||
|
||||
it("verwirft unlesbaren Inhalt, statt daran hängenzubleiben", async () => {
|
||||
ablage.set(SCHLUESSEL, "{kein json")
|
||||
const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
|
||||
|
||||
expect(await geteiltenBelegAbholen()).toBeNull()
|
||||
// Auch der kaputte Eintrag ist weg - sonst liefe er bei jedem Start neu auf.
|
||||
expect(ablage.has(SCHLUESSEL)).toBe(false)
|
||||
})
|
||||
|
||||
it("nimmt einen Standardnamen, wenn keiner mitkam", async () => {
|
||||
ablegen({ base64: PDF_BASE64, zeit: Date.now() })
|
||||
const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
|
||||
|
||||
expect((await geteiltenBelegAbholen())!.name).toBe("beleg.pdf")
|
||||
it("verschluckt sich nicht an einer fehlenden Datei", async () => {
|
||||
const { geteiltenBelegLesen } = await import("./geteilterBeleg")
|
||||
expect(await geteiltenBelegLesen(AUFRUF)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Übergabe an die Tanken-Seite", () => {
|
||||
beforeEach(() => vi.resetModules())
|
||||
|
||||
it("meldet jeden Wechsel an die Horcher", async () => {
|
||||
const { geteiltenBelegSetzen, geteiltenBelegStand, geteiltenBelegHorchen } =
|
||||
await import("./geteilterBeleg")
|
||||
let gerufen = 0
|
||||
const abmelden = geteiltenBelegHorchen(() => gerufen++)
|
||||
const datei = new File([new Uint8Array([1])], "beleg.pdf", { type: "application/pdf" })
|
||||
|
||||
geteiltenBelegSetzen(datei)
|
||||
expect(geteiltenBelegStand()).toBe(datei)
|
||||
expect(gerufen).toBe(1)
|
||||
|
||||
geteiltenBelegSetzen(null)
|
||||
expect(geteiltenBelegStand()).toBeNull()
|
||||
expect(gerufen).toBe(2)
|
||||
|
||||
abmelden()
|
||||
geteiltenBelegSetzen(datei)
|
||||
expect(gerufen).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,54 +1,44 @@
|
||||
/**
|
||||
* Belege, die über das iOS-Teilen-Blatt hereinkommen.
|
||||
*
|
||||
* Die Gegenseite ist `native/teilen/ShareViewController.swift`: sie legt das
|
||||
* geteilte PDF Base64-kodiert in den UserDefaults der App-Gruppe
|
||||
* `group.app.datametric360` ab und öffnet danach die App über
|
||||
* `datametric360://beleg`.
|
||||
* Die Gegenseite ist `native/teilen/ShareViewController.swift`: sie schreibt
|
||||
* das geteilte PDF in den Container der App-Gruppe `group.app.datametric360`
|
||||
* und öffnet danach die App mit dem Pfad im Aufruf:
|
||||
*
|
||||
* `@capacitor/preferences` liest genau diese UserDefaults, sobald man ihm die
|
||||
* Gruppe nennt — deshalb braucht es hier kein eigenes Plugin. Ohne die Gruppe
|
||||
* läse es den privaten Bereich der App und fände nie etwas.
|
||||
* datametric360://beleg?pfad=file:///…/geteilte-belege/1756832400-beleg.pdf
|
||||
*
|
||||
* Diese Datei liest ihn über `@capacitor/filesystem`, das volle `file://`-Pfade
|
||||
* ausdrücklich unterstützt (README: „Simply leave out the `directory` param to
|
||||
* use a full file path"), und löscht die Datei danach.
|
||||
*
|
||||
* DER ERSTE ENTWURF GING ÜBER USERDEFAULTS UND HAT NIE FUNKTIONIERT
|
||||
* ----------------------------------------------------------------
|
||||
* Er legte das PDF base64-kodiert unter `dm360_geteilter_beleg` in
|
||||
* `UserDefaults(suiteName: gruppe)` ab und holte es hier mit
|
||||
* `@capacitor/preferences` und `configure({ group })` wieder heraus — in der
|
||||
* Annahme, das Plugin lese damit die UserDefaults der Gruppe. Das tut es
|
||||
* nicht. Sein iOS-Code (8.0.1, `ios/Sources/PreferencesPlugin/Preferences.swift`)
|
||||
* liest **immer** `UserDefaults.standard` der App; `group` setzt lediglich ein
|
||||
* Schlüssel-Präfix. Gesucht wurde also
|
||||
* `group.app.datametric360.dm360_geteilter_beleg` im eigenen Bereich der App,
|
||||
* während der Eintrag unter dem blanken Schlüssel im Gruppen-Container lag:
|
||||
* falscher Behälter und falscher Schlüssel.
|
||||
*
|
||||
* Sichtbar war davon nur, dass die App aufging und nichts passierte — gemeldet
|
||||
* am 02.09.2026 („es wird kein Tankvorgang angelegt beim Teilen mit der App").
|
||||
*
|
||||
* ABGEHOLT WIRD GENAU EINMAL
|
||||
* --------------------------
|
||||
* Der Eintrag wird nach dem Lesen gelöscht. Bliebe er stehen, käme derselbe
|
||||
* Beleg bei jedem Zurückkehren in die App erneut — und weil der Upload beim
|
||||
* Backend einen Vorgang anlegt, wären das echte Doppeleinträge, keine bloße
|
||||
* Anzeigewiederholung.
|
||||
*
|
||||
* Der Zeitstempel ist die zweite Sicherung: ein Eintrag, der älter ist als
|
||||
* HOECHSTALTER_MS, wird verworfen statt verarbeitet. Er kann nur entstehen,
|
||||
* wenn das Öffnen der App aus der Erweiterung heraus fehlgeschlagen ist; ihn
|
||||
* Tage später beim nächsten Start als „neuen" Beleg zu behandeln, wäre eine
|
||||
* Überraschung, keine Hilfe.
|
||||
* Die Datei wird nach dem Lesen gelöscht. Bliebe sie liegen, käme derselbe
|
||||
* Beleg bei jedem Öffnen erneut — und weil der Upload beim Backend einen
|
||||
* Vorgang anlegt, wären das echte Doppeleinträge.
|
||||
*/
|
||||
|
||||
import { Capacitor } from "@capacitor/core"
|
||||
import { Preferences } from "@capacitor/preferences"
|
||||
import { Filesystem } from "@capacitor/filesystem"
|
||||
|
||||
/** Muss zu `gruppe` in ShareViewController.swift passen. */
|
||||
const GRUPPE = "group.app.datametric360"
|
||||
|
||||
/** Muss zu `schluessel` in ShareViewController.swift passen. */
|
||||
const SCHLUESSEL = "dm360_geteilter_beleg"
|
||||
|
||||
/** Zehn Minuten. Länger her heißt: das Öffnen der App hat nicht geklappt. */
|
||||
const HOECHSTALTER_MS = 10 * 60 * 1000
|
||||
|
||||
let eingerichtet = false
|
||||
|
||||
async function gruppeEinrichten(): Promise<void> {
|
||||
if (eingerichtet) return
|
||||
eingerichtet = true
|
||||
try {
|
||||
await Preferences.configure({ group: GRUPPE })
|
||||
} catch (fehler) {
|
||||
// Auf Android und im Browser gibt es keine App-Gruppe; das ist kein
|
||||
// Fehler, dort kommt über diesen Weg nur nie etwas an.
|
||||
console.warn("Geteilter Beleg: Gruppe nicht einrichtbar", fehler)
|
||||
}
|
||||
}
|
||||
/** Muss zum Schema in ShareViewController.swift passen. */
|
||||
const SCHEMA = "datametric360://beleg"
|
||||
|
||||
function base64ZuDatei(base64: string, name: string): File {
|
||||
const roh = atob(base64)
|
||||
@@ -58,46 +48,58 @@ function base64ZuDatei(base64: string, name: string): File {
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt einen wartenden Beleg ab und räumt ihn weg. `null`, wenn keiner da ist
|
||||
* — der Normalfall bei jedem gewöhnlichen App-Start.
|
||||
* Zieht den Belegpfad aus einem Aufruf der App. `null`, wenn der Aufruf ein
|
||||
* anderer war (die App kann über dasselbe Schema auch anders geöffnet werden).
|
||||
*/
|
||||
export async function geteiltenBelegAbholen(): Promise<File | null> {
|
||||
if (!Capacitor.isNativePlatform()) return null
|
||||
await gruppeEinrichten()
|
||||
|
||||
let roh: string | null = null
|
||||
export function belegpfadAusUrl(url: string | null | undefined): string | null {
|
||||
if (!url || !url.startsWith(SCHEMA)) return null
|
||||
try {
|
||||
roh = (await Preferences.get({ key: SCHLUESSEL })).value
|
||||
} catch (fehler) {
|
||||
console.warn("Geteilter Beleg: nicht lesbar", fehler)
|
||||
return null
|
||||
}
|
||||
if (!roh) return null
|
||||
|
||||
// Erst wegräumen, dann auswerten: ein unlesbarer Eintrag darf nicht bei
|
||||
// jedem Start erneut auflaufen.
|
||||
try {
|
||||
await Preferences.remove({ key: SCHLUESSEL })
|
||||
const frage = url.indexOf("?")
|
||||
if (frage < 0) return null
|
||||
const pfad = new URLSearchParams(url.slice(frage + 1)).get("pfad")
|
||||
return pfad || null
|
||||
} catch {
|
||||
// Bleibt er stehen, fängt ihn beim nächsten Mal die Altersprüfung ab.
|
||||
}
|
||||
|
||||
try {
|
||||
const eintrag = JSON.parse(roh) as { name?: string; base64?: string; zeit?: number }
|
||||
if (!eintrag.base64) return null
|
||||
if (typeof eintrag.zeit === "number" && Date.now() - eintrag.zeit > HOECHSTALTER_MS) {
|
||||
return null
|
||||
}
|
||||
return base64ZuDatei(eintrag.base64, eintrag.name || "beleg.pdf")
|
||||
} catch (fehler) {
|
||||
console.warn("Geteilter Beleg: unlesbarer Eintrag verworfen", fehler)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den geteilten Beleg und räumt ihn weg. `null`, wenn der Aufruf keinen
|
||||
* Pfad trug oder die Datei nicht lesbar war.
|
||||
*/
|
||||
export async function geteiltenBelegLesen(url: string | null | undefined): Promise<File | null> {
|
||||
if (!Capacitor.isNativePlatform()) return null
|
||||
const pfad = belegpfadAusUrl(url)
|
||||
if (!pfad) return null
|
||||
|
||||
let base64: string
|
||||
try {
|
||||
const gelesen = await Filesystem.readFile({ path: pfad })
|
||||
base64 = typeof gelesen.data === "string" ? gelesen.data : ""
|
||||
} catch (fehler) {
|
||||
console.warn("Geteilter Beleg: Datei nicht lesbar", fehler)
|
||||
return null
|
||||
}
|
||||
if (!base64) return null
|
||||
|
||||
// Erst wegräumen, dann zurückgeben: schlägt das Hochladen später fehl, ist
|
||||
// die Datei zwar weg - aber ein liegengebliebener Beleg, der bei jedem
|
||||
// Öffnen erneut hochlädt, wäre der schlimmere Fehler.
|
||||
try {
|
||||
await Filesystem.deleteFile({ path: pfad })
|
||||
} catch {
|
||||
// Halb so wild: der Name trägt einen Zeitstempel, ein Rest überschreibt
|
||||
// nichts und wird nie wieder gelesen - die App kommt nur über einen
|
||||
// frischen Aufruf an einen Pfad.
|
||||
}
|
||||
|
||||
const name = decodeURIComponent(pfad.split("/").pop() || "beleg.pdf")
|
||||
return base64ZuDatei(base64, name)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Übergabe
|
||||
|
||||
Zwischen dem Abholen (App.tsx, beim Start und bei jeder Rückkehr) und dem
|
||||
Zwischen dem Abholen (App.tsx, beim Start und bei jedem Aufruf) und dem
|
||||
Verarbeiten (Tanken.tsx) liegt ein Bildschirmwechsel. Ein kleiner geteilter
|
||||
Speicher nach dem Muster von theme.ts überbrückt ihn — ein Zustand, der
|
||||
außerhalb des Komponentenbaums lebt und den beide Seiten sehen. */
|
||||
|
||||
@@ -15,10 +15,19 @@
|
||||
* immer fehlschlägt, ist schlimmer als kein Knopf. Dort bleiben Ziehen und
|
||||
* Dateiauswahl.
|
||||
*
|
||||
* Auf iOS ist es umgekehrt der Hauptweg: das PDF kommt aus Mail, wird dort
|
||||
* kopiert, und das System zeigt beim Lesen seine eigene Einfügen-Abfrage.
|
||||
* AUF DEM TELEFON LÄUFT ER SEIT DEM 02.09.2026 NATIV
|
||||
* --------------------------------------------------
|
||||
* Dieselbe Grenze der Web-Schnittstelle traf nämlich auch iOS: ein aus Mail
|
||||
* kopiertes PDF ist über `navigator.clipboard.read()` NIE zu bekommen, der
|
||||
* Knopf endete dort zwangsläufig mit „In der Zwischenablage liegt kein PDF"
|
||||
* (gemeldet vom Eigentümer). `UIPasteboard` kennt die Grenze nicht — deshalb
|
||||
* geht die Anfrage auf dem Gerät über ein kleines eigenes Plugin
|
||||
* (native/BelegZwischenablage.swift). Die Einfügen-Rückfrage des Systems
|
||||
* erscheint dabei genauso wie im Web.
|
||||
*/
|
||||
|
||||
import { Capacitor, registerPlugin } from "@capacitor/core"
|
||||
|
||||
/** Nicht nur auf den MIME-Typ verlassen: eine aus dem Explorer/Finder kopierte
|
||||
oder gezogene Datei kommt je nach Browser und Quelle mit leerem `type` an. */
|
||||
export function istPdf(datei: File | null | undefined): boolean {
|
||||
@@ -33,6 +42,21 @@ export function pdfAusDateiliste(dateien: FileList | File[] | null | undefined):
|
||||
|
||||
export class ZwischenablageFehler extends Error {}
|
||||
|
||||
interface BelegZwischenablage {
|
||||
/** Leeres Ergebnis heißt „nichts Passendes kopiert" - kein Fehler. */
|
||||
pdfHolen(): Promise<{ base64?: string; name?: string }>
|
||||
}
|
||||
|
||||
/** Muss zu `jsName` in BelegZwischenablage.swift passen. */
|
||||
const Nativ = registerPlugin<BelegZwischenablage>("BelegZwischenablage")
|
||||
|
||||
function base64ZuDatei(base64: string, name: string): File {
|
||||
const roh = atob(base64)
|
||||
const bytes = new Uint8Array(roh.length)
|
||||
for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i)
|
||||
return new File([bytes], name, { type: "application/pdf" })
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt ein PDF aus der Zwischenablage.
|
||||
*
|
||||
@@ -42,6 +66,25 @@ export class ZwischenablageFehler extends Error {}
|
||||
* nicht herausgibt.
|
||||
*/
|
||||
export async function pdfAusZwischenablage(): Promise<File> {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
let ergebnis: { base64?: string; name?: string }
|
||||
try {
|
||||
ergebnis = await Nativ.pdfHolen()
|
||||
} catch {
|
||||
// Abgelehnte Einfügen-Rückfrage oder ein Gerät ohne das Plugin (eine
|
||||
// .ipa von vor dem 02.09.2026).
|
||||
throw new ZwischenablageFehler(
|
||||
'Kein Zugriff auf die Zwischenablage - bitte "Datei auswählen".',
|
||||
)
|
||||
}
|
||||
if (!ergebnis?.base64) {
|
||||
throw new ZwischenablageFehler(
|
||||
'In der Zwischenablage liegt kein PDF - bitte den Beleg erneut kopieren oder "Datei auswählen".',
|
||||
)
|
||||
}
|
||||
return base64ZuDatei(ergebnis.base64, ergebnis.name || "beleg.pdf")
|
||||
}
|
||||
|
||||
if (!navigator.clipboard?.read) {
|
||||
throw new ZwischenablageFehler(
|
||||
'Dieser Browser gibt die Zwischenablage nicht heraus - bitte "Datei auswählen".',
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useDaten } from "../daten/DatenKontext"
|
||||
import { nachJahrUndMonat } from "../daten/statistik"
|
||||
import { datum, de, eur, schnittpreis, summe } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { SymbolTanken } from "../symbole"
|
||||
import {
|
||||
geteiltenBelegHorchen,
|
||||
geteiltenBelegSetzen,
|
||||
@@ -139,7 +140,9 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
{formularOffen && <TankFormular beiFertig={() => setzeFormularOffen(false)} />}
|
||||
{formularOffen && (
|
||||
<TankFormular beiFertig={() => setzeFormularOffen(false)} geheZu={geheZu} />
|
||||
)}
|
||||
|
||||
<ActionButton
|
||||
variant={formularOffen ? "default" : "primary"}
|
||||
@@ -169,7 +172,13 @@ function monatName(jahr: number, monat: number): string {
|
||||
return new Date(jahr, monat, 1).toLocaleDateString("de-DE", { month: "long" })
|
||||
}
|
||||
|
||||
function TankFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
function TankFormular({
|
||||
beiFertig,
|
||||
geheZu,
|
||||
}: {
|
||||
beiFertig: () => void
|
||||
geheZu: (name: SeitenName, id?: string) => void
|
||||
}) {
|
||||
const { api, fahrzeug } = useDaten()
|
||||
const [zeitpunkt, setzeZeitpunkt] = useState("")
|
||||
const [liter, setzeLiter] = useState("")
|
||||
@@ -236,9 +245,28 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
while (Date.now() < frist) {
|
||||
await new Promise((weiter) => setTimeout(weiter, 1200))
|
||||
try {
|
||||
const e = await api.rest.datenLesen<BelegEntwurf & { fehler?: string; tank_id?: string | null }>(
|
||||
ENTITAETEN.belegErgebnis,
|
||||
)
|
||||
const e = await api.rest.datenLesen<
|
||||
BelegEntwurf & {
|
||||
fehler?: string
|
||||
tank_id?: string | null
|
||||
vorhandener_tank_id?: string | null
|
||||
}
|
||||
>(ENTITAETEN.belegErgebnis)
|
||||
/* Der Beleg gehoert schon zu einem Tankvorgang (belege.py meldet ihn
|
||||
als vorhandener_tank_id): dorthin springen. "Bereits erfasst" ohne
|
||||
Ziel beantwortet die Frage nicht, die man in dem Moment hat. */
|
||||
if (e?.vorhandener_tank_id) {
|
||||
geheZu("fill", e.vorhandener_tank_id)
|
||||
return
|
||||
}
|
||||
/* In einen bestehenden Vorgang nachgetragen - dort steht das
|
||||
Ergebnis. Vorher lief dieser Fall in die 20-Sekunden-Frist und
|
||||
endete mit "Der Server hat den Beleg noch nicht zurueckgemeldet",
|
||||
obwohl er laengst fertig war. */
|
||||
if (e?.tank_id) {
|
||||
geheZu("fill", e.tank_id)
|
||||
return
|
||||
}
|
||||
if (e && e.tank_id == null) {
|
||||
if (e.fehler) {
|
||||
setzeFehler(e.fehler)
|
||||
@@ -296,7 +324,22 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
? { receipt_key: beleg.receipt_key ?? null, receipt_file: beleg.receipt_file ?? null }
|
||||
: {}),
|
||||
})
|
||||
/* Kam der Vorgang aus einem Beleg, gehoert der Nutzer danach in genau
|
||||
diesen Vorgang - so hat er es sich gewuenscht (02.09.2026). Der Dienst
|
||||
liefert keine ID zurueck, deshalb die frisch geschriebene Liste lesen
|
||||
und den Eintrag am receipt_key wiedererkennen. Findet sich nichts,
|
||||
bleibt es beim bisherigen Verhalten: Formular zu, Liste sichtbar. */
|
||||
const schluessel = mitBeleg && beleg ? beleg.receipt_key : null
|
||||
beiFertig()
|
||||
if (schluessel) {
|
||||
try {
|
||||
const liste = await api.tankvorgaengeLesen()
|
||||
const treffer = liste.find((t) => t.receipt_key === schluessel)
|
||||
if (treffer) geheZu("fill", treffer.tank_id)
|
||||
} catch {
|
||||
// Die Liste ist gleich ohnehin da - dann eben ohne Sprung.
|
||||
}
|
||||
}
|
||||
} catch (ursache) {
|
||||
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
|
||||
} finally {
|
||||
@@ -307,6 +350,23 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
return (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Neuer Tankvorgang</span>
|
||||
{/* Solange der Server den Beleg liest, steht hier, was passiert.
|
||||
|
||||
Beim Teilen aus Mail springt die App von selbst hierher, und ohne
|
||||
diese Zeile stand der Nutzer vor einem leeren Formular und wusste
|
||||
nicht, ob etwas laeuft (Ansage des Eigentuemers, 02.09.2026:
|
||||
Zapfsaeule, Kreisel, "Tankbeleg wird verarbeitet ..."). Das Lesen
|
||||
dauert ueblicherweise ein bis drei Sekunden - lang genug, um ohne
|
||||
Anzeige wie ein Haenger zu wirken.
|
||||
|
||||
Nur in der App: das Teilen-Blatt gibt es im Panel nicht. */}
|
||||
{belegLaeuft && (
|
||||
<div className="dm-belegladen" role="status">
|
||||
<SymbolTanken groesse={22} />
|
||||
<span className="dm-spinner" aria-hidden="true" />
|
||||
<span>Tankbeleg wird verarbeitet …</span>
|
||||
</div>
|
||||
)}
|
||||
<Feld label="Zeitpunkt">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
|
||||
@@ -1297,6 +1297,24 @@ button.dm-listenzeile {
|
||||
background: var(--tile-deckend, var(--tile-2));
|
||||
}
|
||||
|
||||
/* Beleg wird gelesen: Zapfsäule, Kreisel, Text - siehe Tanken.tsx. */
|
||||
.dm-belegladen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: var(--sp-3);
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--tile-2);
|
||||
color: var(--fg2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dm-belegladen svg {
|
||||
color: var(--fg2);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Ladezustand */
|
||||
|
||||
/* HIG "Loading": ein unbestimmt langer Vorgang (Netzwerk, kein verlässlicher
|
||||
|
||||
@@ -99,8 +99,20 @@ async def hochladen(k: Koordinator, daten: dict) -> None:
|
||||
vorhandener = await k.ablage.tankvorgang_nach_receipt_key(receipt_key)
|
||||
if vorhandener is not None and vorhandener.get("tank_id") != tank_id:
|
||||
_LOGGER.info("Beleg %s bereits vorhanden, übersprungen", receipt_key)
|
||||
# vorhandener_tank_id zeigt auf den Tankvorgang, der den Beleg schon
|
||||
# trägt. Die Oberflächen springen damit dorthin, statt nur "bereits
|
||||
# erfasst" zu melden - beim Teilen aus Mail ist genau das die Frage,
|
||||
# die der Nutzer hat: welcher Vorgang ist das denn?
|
||||
#
|
||||
# Bewusst ein EIGENES Feld statt tank_id umzudeuten: tank_id ist die
|
||||
# Eingabe des Aufrufs (leer beim Neuanlegen, gesetzt beim Nachtragen),
|
||||
# und das Panel entscheidet daran, wo es den Fehler anzeigt.
|
||||
await k.beleg_ergebnis_veroeffentlichen(
|
||||
{"fehler": "Beleg bereits erfasst", "tank_id": tank_id}
|
||||
{
|
||||
"fehler": "Beleg bereits erfasst",
|
||||
"tank_id": tank_id,
|
||||
"vorhandener_tank_id": vorhandener.get("tank_id"),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":"2026.9.2.23","sha256":"a4b55f9576980ea8f52a5c7782bc0879ff7e4c6561e19f844c44dad903bb87c5","bytes":266680,"gebaut":"2026-09-02T19:03:48Z"}
|
||||
{"version":"2026.9.2.25","sha256":"106acbff40d3a14b59a1759f422e0d4346f29f196d0c3196c80289f6815d6804","bytes":266091,"gebaut":"2026-09-02T20:11:42Z"}
|
||||
Binary file not shown.
@@ -6072,6 +6072,15 @@ function belegErgebnisVerarbeiten(daten) {
|
||||
const kontext = daten.tank_id || belegZielPraefix;
|
||||
belegFehler = { context: kontext, meldung: daten.fehler };
|
||||
if (!daten.tank_id) belegEntwurf = null;
|
||||
/* Der Beleg gehoert schon zu einem Tankvorgang: dorthin springen, statt
|
||||
den Nutzer mit "bereits erfasst" stehen zu lassen. Die Frage, die er
|
||||
in dem Moment hat, ist ja "zu welchem denn?" - siehe belege.py. */
|
||||
if (daten.vorhandener_tank_id) {
|
||||
belegFehler = { context: daten.vorhandener_tank_id, meldung: daten.fehler };
|
||||
belegEntwurf = null;
|
||||
go("fill", daten.vorhandener_tank_id);
|
||||
return;
|
||||
}
|
||||
} else if (daten.tank_id) {
|
||||
// Direkt übernommener Beleg (Beleg nachtragen/ersetzen in der
|
||||
// Einzelbeleg-Ansicht) - die neuen Werte kommen bereits über
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "audi_dashboard",
|
||||
"name": "Audi Dashboard",
|
||||
"version": "2026.9.2.23",
|
||||
"version": "2026.9.2.25",
|
||||
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
||||
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
||||
"codeowners": ["@paul"],
|
||||
|
||||
Reference in New Issue
Block a user