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 <[email protected]>
This commit is contained in:
@@ -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")
|
||||
it("verschluckt sich nicht an einer fehlenden Datei", async () => {
|
||||
const { geteiltenBelegLesen } = await import("./geteilterBeleg")
|
||||
expect(await geteiltenBelegLesen(AUFRUF)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
expect(await geteiltenBelegAbholen()).toBeNull()
|
||||
})
|
||||
describe("Übergabe an die Tanken-Seite", () => {
|
||||
beforeEach(() => vi.resetModules())
|
||||
|
||||
it("verwirft unlesbaren Inhalt, statt daran hängenzubleiben", async () => {
|
||||
ablage.set(SCHLUESSEL, "{kein json")
|
||||
const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
|
||||
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" })
|
||||
|
||||
expect(await geteiltenBelegAbholen()).toBeNull()
|
||||
// Auch der kaputte Eintrag ist weg - sonst liefe er bei jedem Start neu auf.
|
||||
expect(ablage.has(SCHLUESSEL)).toBe(false)
|
||||
})
|
||||
geteiltenBelegSetzen(datei)
|
||||
expect(geteiltenBelegStand()).toBe(datei)
|
||||
expect(gerufen).toBe(1)
|
||||
|
||||
it("nimmt einen Standardnamen, wenn keiner mitkam", async () => {
|
||||
ablegen({ base64: PDF_BASE64, zeit: Date.now() })
|
||||
const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
|
||||
geteiltenBelegSetzen(null)
|
||||
expect(geteiltenBelegStand()).toBeNull()
|
||||
expect(gerufen).toBe(2)
|
||||
|
||||
expect((await geteiltenBelegAbholen())!.name).toBe("beleg.pdf")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user